From 2b33a69368a5a4c940884595c604c08bcec7dbb1 Mon Sep 17 00:00:00 2001 From: ykisana Date: Tue, 6 Oct 2026 19:46:06 -0600 Subject: [PATCH] fix: usage limit-reset --- Normal/Services/UsageLimitService.swift | 4 ++ .../Services/UsageLimitServiceTests.swift | 16 +++++++ NormalTests/Shared/UsageDTOTests.swift | 44 +++++++++++++++++-- Shared/SharedStoreProviding.swift | 4 ++ Shared/UsageDTO.swift | 8 ++++ 5 files changed, 73 insertions(+), 3 deletions(-) diff --git a/Normal/Services/UsageLimitService.swift b/Normal/Services/UsageLimitService.swift index 228a01f..72156f8 100644 --- a/Normal/Services/UsageLimitService.swift +++ b/Normal/Services/UsageLimitService.swift @@ -49,6 +49,10 @@ final class UsageLimitService { logger.error("Dropped \(limits.count - dtos.count, privacy: .public) limit(s) that failed to encode") } sharedStore.saveUsageLimits(dtos) + let previous = sharedStore.loadUsageLimitConfig() + if previous.resetMinutes != config.resetMinutes { + sharedStore.moveUsageDay(from: previous.period, to: config.period, on: date) + } sharedStore.saveUsageLimitConfig(config) syncShields(on: date) diff --git a/NormalTests/Services/UsageLimitServiceTests.swift b/NormalTests/Services/UsageLimitServiceTests.swift index bd1cb06..ea3e92a 100644 --- a/NormalTests/Services/UsageLimitServiceTests.swift +++ b/NormalTests/Services/UsageLimitServiceTests.swift @@ -230,6 +230,22 @@ struct UsageLimitServiceTests { #expect(h.shield.shieldedLimitIDs.isEmpty) } + @Test func movingTheResetPastTheCurrentTimeLiftsASpentLimitAtTheNewReset() { + let h = makeService() + let limit = UsageLimit(minutesPerDay: 30) + let midnight = Calendar.current.startOfDay(for: day) + let spentAt = midnight + .hours(15) + h.service.registerAll([limit], config: config, on: spentAt) + h.store.recordUsageState(.reached, for: limit.id, on: spentAt) + + let later = UsageLimitConfig(resetMinutes: 16 * 60) + h.service.registerAll([limit], config: later, on: spentAt + .minutes(30)) + #expect(h.shield.shieldedLimitIDs == [limit.id]) + + h.service.registerAll([limit], config: later, on: midnight + .hours(16) + .minutes(1)) + #expect(h.shield.shieldedLimitIDs.isEmpty) + } + @Test func deletingASpentLimitLiftsItsShield() { let h = makeService() let limit = UsageLimit(minutesPerDay: 30) diff --git a/NormalTests/Shared/UsageDTOTests.swift b/NormalTests/Shared/UsageDTOTests.swift index cb3d252..91cbb6d 100644 --- a/NormalTests/Shared/UsageDTOTests.swift +++ b/NormalTests/Shared/UsageDTOTests.swift @@ -233,15 +233,53 @@ struct UsageDTOTests { #expect(state.state(for: id, on: date("2026-03-15T05:00:00-04:00"), period: later) == .under) } - @Test func movingTheResetEarlierCannotRefundASpentRecord() { + @Test func movingTheResetEarlierReleasesASpentRecordAtTheNewReset() { let noon = date("2026-03-14T12:00:00-04:00") let later = newYorkPeriod(resetAt: 4) let state = UsageDayStateDTO .fresh(on: noon, period: later) .recording(.reached, for: id, on: noon, period: later) + .moving(from: later, to: nyPeriod, on: noon + .hours(1)) - #expect(state.state(for: id, on: date("2026-03-15T01:00:00-04:00"), period: nyPeriod) == .reached) - #expect(state.state(for: id, on: date("2026-03-15T09:00:00-04:00"), period: nyPeriod) == .under) + #expect(state.state(for: id, on: date("2026-03-14T23:00:00-04:00"), period: nyPeriod) == .reached) + #expect(state.state(for: id, on: date("2026-03-15T00:30:00-04:00"), period: nyPeriod) == .under) + } + + @Test func movingTheResetPastTheCurrentTimeReleasesASpentRecordAtTheNewReset() { + let spentAt = date("2026-03-14T15:00:00-04:00") + let later = newYorkPeriod(resetAt: 16) + let state = UsageDayStateDTO + .fresh(on: spentAt, period: nyPeriod) + .recording(.reached, for: id, on: spentAt, period: nyPeriod) + .moving(from: nyPeriod, to: later, on: date("2026-03-14T15:30:00-04:00")) + + #expect(state.state(for: id, on: date("2026-03-14T15:59:00-04:00"), period: later) == .reached) + #expect(state.state(for: id, on: date("2026-03-14T16:01:00-04:00"), period: later) == .under) + } + + @Test func movingTheResetDoesNotReviveAnExpiredRecord() { + let yesterday = date("2026-03-13T12:00:00-04:00") + let now = date("2026-03-14T15:30:00-04:00") + let later = newYorkPeriod(resetAt: 16) + let state = UsageDayStateDTO + .fresh(on: yesterday, period: nyPeriod) + .recording(.reached, for: id, on: yesterday, period: nyPeriod) + .moving(from: nyPeriod, to: later, on: now) + + #expect(state.state(for: id, on: now, period: later) == .under) + } + + @Test func movingTheResetKeepsTheOverride() { + let noon = date("2026-03-14T12:00:00-04:00") + let later = newYorkPeriod(resetAt: 16) + let state = UsageDayStateDTO + .fresh(on: noon, period: nyPeriod) + .overriding(on: noon, period: nyPeriod) + .moving(from: nyPeriod, to: later, on: noon) + + #expect(state.isOverridden) + #expect(state.isStale(on: date("2026-03-14T15:59:00-04:00"), period: later) == false) + #expect(state.isStale(on: date("2026-03-14T16:01:00-04:00"), period: later)) } @Test func aReportClaimingMoreUseThanTimeSinceTheResetIsImplausible() { diff --git a/Shared/SharedStoreProviding.swift b/Shared/SharedStoreProviding.swift index 866dd8c..353942b 100644 --- a/Shared/SharedStoreProviding.swift +++ b/Shared/SharedStoreProviding.swift @@ -78,6 +78,10 @@ extension SharedStoreProviding { saveUsageDayState(.fresh(on: date, period: period)) } + func moveUsageDay(from old: UsagePeriod, to new: UsagePeriod, on date: Date = .now) { + mutateUsageDayState { $0.moving(from: old, to: new, on: date) } + } + func overrideUsageDay(on date: Date = .now) { saveUsageDayState(loadUsageDayState().overriding(on: date, period: usagePeriod)) } diff --git a/Shared/UsageDTO.swift b/Shared/UsageDTO.swift index 8e296a6..2efce21 100644 --- a/Shared/UsageDTO.swift +++ b/Shared/UsageDTO.swift @@ -117,6 +117,14 @@ nonisolated struct UsageDayStateDTO: Codable, Sendable, Equatable { return updated } + func moving(from old: UsagePeriod, to new: UsagePeriod, on date: Date) -> Self { + guard !isStale(on: date, period: old) else { return .fresh(on: date, period: new) } + var updated = self + updated.dayKey = new.key(for: date) + updated.encodedExpiresAt = new.nextReset(after: date) + return updated + } + func overriding(on date: Date, period: UsagePeriod = UsagePeriod()) -> Self { var updated = Self.fresh(on: date, period: period) updated.encodedIsOverridden = true