Files
2026-07-13 12:22:33 +08:00

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)
}
}