diff --git a/OpenAppLock/Models/OpenAppLockSchemaMigrationPlan.swift b/OpenAppLock/Models/OpenAppLockSchemaMigrationPlan.swift index 0ccd097..5d36a27 100644 --- a/OpenAppLock/Models/OpenAppLockSchemaMigrationPlan.swift +++ b/OpenAppLock/Models/OpenAppLockSchemaMigrationPlan.swift @@ -23,39 +23,62 @@ enum OpenAppLockSchemaMigrationPlan: SchemaMigrationPlan { toVersion: OpenAppLockSchemaV2.self, willMigrate: { context in Diag.log(.migration, "migrating schema from V1 to V2") - let latestAllowedStartMinutes = 23 * 60 + 45 - let minimumRuleDurationMinutes = 15 - var didClampRules = false - let rules = try context.fetch(FetchDescriptor()) - for rule in rules where rule.kind == .schedule { - if rule.startMinutes % 5 != 0 { - rule.startMinutes = rule.startMinutes - rule.startMinutes % 5 - didClampRules = true - Diag.log(.migration, "clamped startMinutes for rule-\(rule.id.logTag)") - } - if rule.endMinutes % 5 != 0 { - rule.endMinutes = rule.endMinutes - rule.endMinutes % 5 - didClampRules = true - Diag.log(.migration, "clamped endMinutes for rule-\(rule.id.logTag)") - } - if rule.startMinutes > latestAllowedStartMinutes { - rule.startMinutes = latestAllowedStartMinutes - didClampRules = true - Diag.log(.migration, "moved startMinutes to \(latestAllowedStartMinutes) minutes for rule-\(rule.id.logTag)") - } - if rule.endMinutes > rule.startMinutes, - rule.endMinutes - rule.startMinutes < minimumRuleDurationMinutes { - rule.endMinutes = rule.startMinutes + minimumRuleDurationMinutes - didClampRules = true - Diag.log(.migration, "moved endMinutes to \(minimumRuleDurationMinutes) minutes after startMinutes for rule-\(rule.id.logTag)") - } - } - try context.save() - if didClampRules { + let changed = try MigrationHelpers.prepareV1DataForMigration(context) + if changed.count > 0 { AppGroup.defaults.set(true, forKey: AppGroup.migrationDataChangedKey) Diag.log(.migration, "clamped schedule rule times; flagged migrated-data banner") } }, didMigrate: nil ) } + +enum MigrationHelpers { + /// Perform the schema migration and get the IDs of rules that changed. + static func prepareV1DataForMigration(_ context: ModelContext) throws -> Set { + let latestAllowedStartMinutes = 23 * 60 + 45 + let minimumRuleDurationMinutes = 15 + + var changed: Set = Set() + + let rules = try context.fetch(FetchDescriptor()) + for rule in rules where rule.kind == .schedule { + if rule.startMinutes > latestAllowedStartMinutes { + let diff = rule.startMinutes - latestAllowedStartMinutes + rule.startMinutes = latestAllowedStartMinutes + rule.endMinutes = rule.endMinutes - diff + changed.insert(rule.id) + Diag.log(.migration, "moved startMinutes to \(latestAllowedStartMinutes) minutes for rule-\(rule.id.logTag)") + } + + if rule.endMinutes > rule.startMinutes, + rule.endMinutes - rule.startMinutes < minimumRuleDurationMinutes { + rule.endMinutes = rule.startMinutes + minimumRuleDurationMinutes + + if rule.endMinutes == 24 * 60 { + rule.endMinutes = 0 + } + + changed.insert(rule.id) + Diag.log(.migration, "moved endMinutes to \(minimumRuleDurationMinutes) minutes after startMinutes for rule-\(rule.id.logTag)") + + } + + if rule.startMinutes % 5 != 0 { + rule.startMinutes = rule.startMinutes - rule.startMinutes % 5 + changed.insert(rule.id) + Diag.log(.migration, "clamped startMinutes for rule-\(rule.id.logTag)") + } + + if rule.endMinutes % 5 != 0 { + rule.endMinutes = rule.endMinutes - rule.endMinutes % 5 + changed.insert(rule.id) + Diag.log(.migration, "clamped endMinutes for rule-\(rule.id.logTag)") + } + } + + try context.save() + + return changed + } +} diff --git a/OpenAppLockTests/OpenAppLockSchemaMigrationPlanTests.swift b/OpenAppLockTests/OpenAppLockSchemaMigrationPlanTests.swift new file mode 100644 index 0000000..c947735 --- /dev/null +++ b/OpenAppLockTests/OpenAppLockSchemaMigrationPlanTests.swift @@ -0,0 +1,132 @@ +// +// OpenAppLockSchemaMigrationPlanTests.swift +// OpenAppLock +// +// Created by Brendan Chen on 2026.09.11. +// + +import Foundation +import Testing +@testable import OpenAppLock +import SwiftData + +@MainActor +@Suite("v1 to v2 migration tests") +struct V1ToV2MigrationTests { + /// Since we are operating on the V1 container, we need a custom `ModelContext`. + func makeModelContext() throws -> ModelContext { + let container = try ModelContainer( + for: Schema(versionedSchema: OpenAppLockSchemaV1.self), + migrationPlan: OpenAppLockSchemaMigrationPlan.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + + return ModelContext(container) + } + + @Test("Rule with 23:45-23:50 changes to 23:45-00:00") + func testMinimumDurationMigrationWrapsToMidnight() throws { + let context = try makeModelContext() + let model = OpenAppLockSchemaV1.BlockingRule(name: "Test", configuration: .schedule(ScheduleConfig(startMinutes: 23 * 60 + 45, endMinutes: 23 * 60 + 50))) + context.insert(model) + let changed = try MigrationHelpers.prepareV1DataForMigration(context) + + guard let updatedModel = context.model(for: model.id) as? OpenAppLockSchemaV1.BlockingRule else { + Issue.record("Unable to find matching BlockingRule \(model.id)") + return + } + + #expect(updatedModel.schedule.startMinutes == 23 * 60 + 45) + #expect(updatedModel.schedule.endMinutes == 0) + #expect(changed.count == 1) + } + + @Test("Rule with 23:50-23:51 changes to 23:45-00:00") + func testMidnightStartShiftRunsBeforeMinimumDurationMigration() throws { + let context = try makeModelContext() + + let model = OpenAppLockSchemaV1.BlockingRule(name: "Test", configuration: .schedule(ScheduleConfig(startMinutes: 23 * 60 + 50, endMinutes: 23 * 60 + 51))) + context.insert(model) + let changed = try MigrationHelpers.prepareV1DataForMigration(context) + + guard let updatedModel = context.model(for: model.id) as? OpenAppLockSchemaV1.BlockingRule else { + Issue.record("Unable to find matching BlockingRule \(model.id)") + return + } + + #expect(updatedModel.schedule.startMinutes == 23 * 60 + 45) + #expect(updatedModel.schedule.endMinutes == 0) + #expect(changed.count == 1) + } + + @Test("Rule with 09:00-09:01 changes to 09:00-09:15") + func testMinimumDurationMigrationRunsBeforeIntervalClamp() throws { + let context = try makeModelContext() + + let model = OpenAppLockSchemaV1.BlockingRule(name: "Test", configuration: .schedule(ScheduleConfig(startMinutes: 9 * 60, endMinutes: 9 * 60 + 1))) + context.insert(model) + let changed = try MigrationHelpers.prepareV1DataForMigration(context) + + guard let updatedModel = context.model(for: model.id) as? OpenAppLockSchemaV1.BlockingRule else { + Issue.record("Unable to find matching BlockingRule \(model.id)") + return + } + + #expect(updatedModel.schedule.startMinutes == 9 * 60) + #expect(updatedModel.schedule.endMinutes == 9 * 60 + 15) + #expect(changed.count == 1) + } + + @Test("Rule with 09:00-09:16 changes to 09:00-09:15") + func testIntervalClamp() throws { + let context = try makeModelContext() + + let model = OpenAppLockSchemaV1.BlockingRule(name: "Test", configuration: .schedule(ScheduleConfig(startMinutes: 9 * 60, endMinutes: 9 * 60 + 16))) + context.insert(model) + let changed = try MigrationHelpers.prepareV1DataForMigration(context) + + guard let updatedModel = context.model(for: model.id) as? OpenAppLockSchemaV1.BlockingRule else { + Issue.record("Unable to find matching BlockingRule \(model.id)") + return + } + + #expect(updatedModel.schedule.startMinutes == 9 * 60) + #expect(updatedModel.schedule.endMinutes == 9 * 60 + 15) + #expect(changed.count == 1) + } + + @Test("Rule with 23:56-23:54 changes to 23:45-23:40") + func testStartAndEndTimeShiftForMidnightScheduledRule() throws { + let context = try makeModelContext() + + let model = OpenAppLockSchemaV1.BlockingRule(name: "Test", configuration: .schedule(ScheduleConfig(startMinutes: 23 * 60 + 56, endMinutes: 23 * 60 + 54))) + context.insert(model) + let changed = try MigrationHelpers.prepareV1DataForMigration(context) + + guard let updatedModel = context.model(for: model.id) as? OpenAppLockSchemaV1.BlockingRule else { + Issue.record("Unable to find matching BlockingRule \(model.id)") + return + } + + #expect(updatedModel.schedule.startMinutes == 23 * 60 + 45) + #expect(updatedModel.schedule.endMinutes == 23 * 60 + 40) + #expect(changed.count == 1) + } + + @Test("Rule with 00:02-00:01 changes to 00:00-00:00") + func testIntervalClampForOverlappingRule() throws { + let context = try makeModelContext() + + let model = OpenAppLockSchemaV1.BlockingRule(name: "Test", configuration: .schedule(ScheduleConfig(startMinutes: 2, endMinutes: 1))) + context.insert(model) + let changed = try MigrationHelpers.prepareV1DataForMigration(context) + + guard let updatedModel = context.model(for: model.id) as? OpenAppLockSchemaV1.BlockingRule else { + Issue.record("Unable to find matching BlockingRule \(model.id)") + return + } + + #expect(updatedModel.schedule.startMinutes == 0) + #expect(updatedModel.schedule.endMinutes == 0) + #expect(changed.count == 1) + } +}