283 lines
13 KiB
Swift
283 lines
13 KiB
Swift
import AdaptiveReplayKit
|
|
import Foundation
|
|
import Testing
|
|
|
|
/// Hand-computed metric checks against small synthetic traces, plus determinism and baseline
|
|
/// (manual/fixed) sanity checks for `ReplayEngine`. Trace construction stays in-code (no fixture
|
|
/// files): each trace is small enough that its expected metrics can be derived by hand in the
|
|
/// comments beside it, which is the actual verification for requirement 4 ("metric math verified
|
|
/// against hand-computed values").
|
|
struct AdaptiveReplayEngineTests {
|
|
private static let epoch = Date(timeIntervalSinceReferenceDate: 0)
|
|
|
|
private func at(_ seconds: TimeInterval) -> Date {
|
|
Self.epoch.addingTimeInterval(seconds)
|
|
}
|
|
|
|
/// A one-hour span (t=0...3600) pinned by two `decision` boundary records, `FixedIntervalPolicy`
|
|
/// refreshing every 10 minutes, and four `menuOpen` events chosen so each falls a different,
|
|
/// hand-computable number of seconds after the preceding simulated refresh.
|
|
///
|
|
/// Refreshes land at t=600,1200,...,3600 (6 total: cursor starts at 0, and 3600 <= end is still
|
|
/// included). Staleness samples: menuOpen@50 -> 50-0=50 (no refresh yet, falls back to
|
|
/// time-since-trace-start); @900 -> 900-600=300; @2200 -> 2200-1800=400; @3500 -> 3500-3000=500.
|
|
/// mean=(50+300+400+500)/4=312.5, median (nearest-rank, sorted=[50,300,400,500])=sorted[1]=300,
|
|
/// p95=sorted[3]=500.
|
|
private func fixedCadenceTrace() -> [AdaptiveRefreshTraceRecord] {
|
|
[
|
|
.decision(
|
|
timestamp: self.at(0),
|
|
menuAgeSeconds: nil,
|
|
lowPowerModeEnabled: false,
|
|
thermalState: .nominal,
|
|
reason: "longIdle",
|
|
delaySeconds: 1800),
|
|
.decision(
|
|
timestamp: self.at(3600),
|
|
menuAgeSeconds: nil,
|
|
lowPowerModeEnabled: false,
|
|
thermalState: .nominal,
|
|
reason: "longIdle",
|
|
delaySeconds: 1800),
|
|
.menuOpen(timestamp: self.at(50)),
|
|
.menuOpen(timestamp: self.at(900)),
|
|
.menuOpen(timestamp: self.at(2200)),
|
|
.menuOpen(timestamp: self.at(3500)),
|
|
]
|
|
}
|
|
|
|
@Test
|
|
func `fixed cadence refresh count and staleness match hand computation`() throws {
|
|
let metrics = ReplayEngine.run(trace: self.fixedCadenceTrace(), policy: FixedIntervalPolicy(minutes: 10))
|
|
|
|
#expect(metrics.totalRefreshCount == 6)
|
|
#expect(metrics.simulatedSpanSeconds == 3600.0)
|
|
#expect(metrics.refreshCountPer24h == 144.0) // 6 refreshes/hour * 24h
|
|
#expect(metrics.interactionAdvanceCount == 0) // fixed cadence never advances on interaction
|
|
|
|
let staleness = try #require(metrics.stalenessAtMenuOpen)
|
|
#expect(staleness.sampleCount == 4)
|
|
#expect(staleness.mean == 312.5)
|
|
#expect(staleness.median == 300.0)
|
|
#expect(staleness.p95 == 500.0)
|
|
}
|
|
|
|
@Test
|
|
func `replaying the same trace and policy twice is deterministic`() {
|
|
let trace = self.fixedCadenceTrace()
|
|
let first = ReplayEngine.run(trace: trace, policy: FixedIntervalPolicy(minutes: 10))
|
|
let second = ReplayEngine.run(trace: trace, policy: FixedIntervalPolicy(minutes: 10))
|
|
#expect(first == second)
|
|
}
|
|
|
|
@Test
|
|
func `manual policy never schedules a refresh`() {
|
|
let metrics = ReplayEngine.run(trace: self.fixedCadenceTrace(), policy: ManualPolicy())
|
|
#expect(metrics.totalRefreshCount == 0)
|
|
#expect(metrics.refreshCountPer24h == 0.0)
|
|
}
|
|
|
|
@Test
|
|
func `a trace with no menu-open events reports no staleness stats`() {
|
|
let trace: [AdaptiveRefreshTraceRecord] = [
|
|
.decision(
|
|
timestamp: self.at(0),
|
|
menuAgeSeconds: nil,
|
|
lowPowerModeEnabled: false,
|
|
thermalState: .nominal,
|
|
reason: "longIdle",
|
|
delaySeconds: 1800),
|
|
.refreshCompleted(timestamp: self.at(1800)),
|
|
]
|
|
let metrics = ReplayEngine.run(trace: trace, policy: AdaptiveReplayPolicy())
|
|
#expect(metrics.stalenessAtMenuOpen == nil)
|
|
}
|
|
|
|
/// A single constrained (`lowPowerModeEnabled: true`) sample at t=0, held for the whole
|
|
/// 0...1000 span (no later sample overrides it), replayed against `FixedIntervalPolicy(2m)`
|
|
/// (120s, well under the 30-minute constrained floor).
|
|
///
|
|
/// decide() is called at cursor = 0,120,240,...,960 (9 calls: the call at 960 computes
|
|
/// next=1080 > end=1000 and breaks before appending). All 9 calls see the constrained sample,
|
|
/// and every one returns a 120s delay, so all 9 are violations. 8 of those calls' `next` landed
|
|
/// at or before 1000 (120,240,...,960), so 8 refreshes were recorded.
|
|
private func constrainedTrace() -> [AdaptiveRefreshTraceRecord] {
|
|
[
|
|
.decision(
|
|
timestamp: self.at(0),
|
|
menuAgeSeconds: nil,
|
|
lowPowerModeEnabled: true,
|
|
thermalState: .nominal,
|
|
reason: "constrained",
|
|
delaySeconds: 1800),
|
|
.menuOpen(timestamp: self.at(1000)),
|
|
]
|
|
}
|
|
|
|
@Test
|
|
func `a policy that ignores the constrained floor is flagged non-compliant`() {
|
|
let metrics = ReplayEngine.run(trace: self.constrainedTrace(), policy: FixedIntervalPolicy(minutes: 2))
|
|
|
|
#expect(metrics.totalRefreshCount == 8)
|
|
#expect(metrics.constrainedCompliance.constrainedDecisionCount == 9)
|
|
#expect(metrics.constrainedCompliance.violationCount == 9)
|
|
#expect(!metrics.constrainedCompliance.isCompliant)
|
|
}
|
|
|
|
@Test
|
|
func `the shared adaptive policy honors the constrained floor`() {
|
|
let metrics = ReplayEngine.run(trace: self.constrainedTrace(), policy: AdaptiveReplayPolicy())
|
|
|
|
#expect(metrics.constrainedCompliance.constrainedDecisionCount == 1)
|
|
#expect(metrics.constrainedCompliance.violationCount == 0)
|
|
#expect(metrics.constrainedCompliance.isCompliant)
|
|
// The menu open at t=1000 is still under low-power, so the advance-check itself also
|
|
// returns the constrained floor (candidate = 1000+1800 = 2800), which is later than the
|
|
// already-scheduled t=1800 tick — no advance is taken. Mirrors the real
|
|
// `noteMenuOpened(at:)` guard: opening the menu while constrained never shortens the timer.
|
|
#expect(metrics.interactionAdvanceCount == 0)
|
|
}
|
|
|
|
@Test
|
|
func `an empty trace reports zero metrics without crashing`() {
|
|
let metrics = ReplayEngine.run(trace: [], policy: AdaptiveReplayPolicy())
|
|
#expect(metrics.totalRefreshCount == 0)
|
|
#expect(metrics.simulatedSpanSeconds == 0.0)
|
|
#expect(metrics.stalenessAtMenuOpen == nil)
|
|
#expect(metrics.constrainedCompliance.constrainedDecisionCount == 0)
|
|
#expect(metrics.interactionAdvanceCount == 0)
|
|
}
|
|
|
|
// MARK: - Interaction-advance path (mirrors UsageStore.noteMenuOpened(at:))
|
|
|
|
/// A 300-second span with a single tick boundary at t=0 (which alone would schedule a longIdle
|
|
/// refresh at t=1800, far past the trace's end) and one `menuOpen` at t=50 landing inside that
|
|
/// tick's window.
|
|
///
|
|
/// Hand computation for `AdaptiveReplayPolicy` (`advancesOnInteraction == true`):
|
|
/// - cursor=0: decide(now:0, lastMenuOpenAt: nil) -> longIdle, delay=1800 -> next=1800.
|
|
/// menuOpen@50 falls in (0, 1800]: decide(now:50, lastMenuOpenAt:50) (age 0) ->
|
|
/// recentInteraction, delay=120 -> candidate=170. 170 < 1800, so the schedule advances:
|
|
/// next=170 (1 advance so far). next(170) <= end(300), so a refresh lands at t=170.
|
|
/// - cursor=170: decide(now:170, lastMenuOpenAt:50) (age 120 <= 300 recentInteractionThreshold)
|
|
/// -> recentInteraction, delay=120 -> next=290. No more menu opens to scan. 290 <= 300, so a
|
|
/// refresh lands at t=290.
|
|
/// - cursor=290: decide(now:290, lastMenuOpenAt:50) (age 240 <= 300) -> recentInteraction,
|
|
/// delay=120 -> next=410. 410 > end(300), loop breaks without appending.
|
|
///
|
|
/// Total: 2 refreshes (170, 290), 1 interaction advance. Without the advance, the *only*
|
|
/// schedulable event would be the t=1800 tick, which falls entirely outside this 300s span —
|
|
/// i.e. `totalRefreshCount` would be 0. The non-zero count here is only possible because the
|
|
/// engine reproduces the interaction-advance path.
|
|
private func menuOpenAdvanceTrace() -> [AdaptiveRefreshTraceRecord] {
|
|
[
|
|
.decision(
|
|
timestamp: self.at(0),
|
|
menuAgeSeconds: nil,
|
|
lowPowerModeEnabled: false,
|
|
thermalState: .nominal,
|
|
reason: "longIdle",
|
|
delaySeconds: 1800),
|
|
.decision(
|
|
timestamp: self.at(300),
|
|
menuAgeSeconds: nil,
|
|
lowPowerModeEnabled: false,
|
|
thermalState: .nominal,
|
|
reason: "longIdle",
|
|
delaySeconds: 1800),
|
|
.menuOpen(timestamp: self.at(50)),
|
|
]
|
|
}
|
|
|
|
@Test
|
|
func `a menu open pulls the adaptive schedule forward, matching hand computation`() {
|
|
let metrics = ReplayEngine.run(trace: self.menuOpenAdvanceTrace(), policy: AdaptiveReplayPolicy())
|
|
|
|
#expect(metrics.totalRefreshCount == 2)
|
|
#expect(metrics.interactionAdvanceCount == 1)
|
|
}
|
|
|
|
@Test
|
|
func `a policy that does not advance on interaction ignores the same menu open`() {
|
|
// Same trace, but FixedIntervalPolicy(30m) never overrides `advancesOnInteraction` (stays
|
|
// false), matching fixed-cadence refresh frequencies in the real app, which never wire
|
|
// `noteMenuOpened(at:)`'s advance check at all. The t=1800 tick falls outside the 300s
|
|
// span, so nothing is scheduled — the menu open at t=50 has zero scheduling effect.
|
|
let metrics = ReplayEngine.run(trace: self.menuOpenAdvanceTrace(), policy: FixedIntervalPolicy(minutes: 30))
|
|
|
|
#expect(metrics.totalRefreshCount == 0)
|
|
#expect(metrics.interactionAdvanceCount == 0)
|
|
}
|
|
|
|
@Test
|
|
func `a recorded timerAdvanced ground-truth event agrees with the engine's own recomputation`() throws {
|
|
// The menuOpen ground truth plus a timerAdvanced record for the accepted schedule change.
|
|
// The offline audit checks that record against the policy's recomputed candidate.
|
|
let menuOpenAt = self.at(50)
|
|
let recordedCandidate = self.at(170) // menuOpenAt + recentInteractionDelay (120s)
|
|
var trace = self.menuOpenAdvanceTrace()
|
|
trace.append(.timerAdvanced(
|
|
timestamp: menuOpenAt,
|
|
previousScheduledAt: self.at(1800),
|
|
candidateScheduledAt: recordedCandidate,
|
|
reason: "recentInteraction",
|
|
delaySeconds: 120))
|
|
|
|
let policy = AdaptiveReplayPolicy()
|
|
let recomputed = policy.decide(ReplayPolicyInput(
|
|
now: menuOpenAt,
|
|
lastMenuOpenAt: menuOpenAt,
|
|
lowPowerModeEnabled: false,
|
|
thermalState: .nominal))
|
|
let recomputedCandidate = try menuOpenAt.addingTimeInterval(#require(recomputed.delaySeconds))
|
|
|
|
#expect(recomputedCandidate == recordedCandidate)
|
|
|
|
// The recorded event doesn't change the metrics (the engine recomputes advances itself,
|
|
// independent of any timerAdvanced lines in the trace); replaying still reproduces the
|
|
// same two refreshes as the trace without the extra record.
|
|
let metrics = ReplayEngine.run(trace: trace, policy: policy)
|
|
#expect(metrics.totalRefreshCount == 2)
|
|
#expect(metrics.interactionAdvanceCount == 1)
|
|
}
|
|
|
|
/// Two menu opens in the same tick window: the second one's candidate is compared against the
|
|
/// *already-advanced* schedule from the first, not the original tick schedule — mirroring a
|
|
/// real second `noteMenuOpened(at:)` call tightening an already-shortened sleep.
|
|
///
|
|
/// - cursor=0: decide -> longIdle, next=1800. menuOpen@50: candidate=170 < 1800 -> next=170
|
|
/// (advance 1). menuOpen@100 also falls in (0, 170]? No — 100 <= 170 is true, so it's still
|
|
/// scanned: decide(now:100, lastMenuOpenAt:100) -> recentInteraction, candidate=220. Is 220 <
|
|
/// next(170)? No, so this second menu open does *not* further advance the schedule (it would
|
|
/// move the refresh *later*, which `shouldAdvanceAdaptiveTimer` never does). next stays 170.
|
|
/// - Total: 1 refresh (170), 1 advance (only the first menu open's candidate beat the schedule).
|
|
private func twoMenuOpensSameWindowTrace() -> [AdaptiveRefreshTraceRecord] {
|
|
[
|
|
.decision(
|
|
timestamp: self.at(0),
|
|
menuAgeSeconds: nil,
|
|
lowPowerModeEnabled: false,
|
|
thermalState: .nominal,
|
|
reason: "longIdle",
|
|
delaySeconds: 1800),
|
|
.decision(
|
|
timestamp: self.at(170),
|
|
menuAgeSeconds: nil,
|
|
lowPowerModeEnabled: false,
|
|
thermalState: .nominal,
|
|
reason: "longIdle",
|
|
delaySeconds: 1800),
|
|
.menuOpen(timestamp: self.at(50)),
|
|
.menuOpen(timestamp: self.at(100)),
|
|
]
|
|
}
|
|
|
|
@Test
|
|
func `a later menu open in the same window cannot postpone an earlier advance`() {
|
|
let metrics = ReplayEngine.run(trace: self.twoMenuOpensSameWindowTrace(), policy: AdaptiveReplayPolicy())
|
|
|
|
#expect(metrics.totalRefreshCount == 1)
|
|
#expect(metrics.interactionAdvanceCount == 1)
|
|
}
|
|
}
|