Files
steipete--codexbar/Tests/AdaptiveReplayKitTests/AdaptiveReplayTraceParserTests.swift
2026-07-13 12:22:33 +08:00

323 lines
14 KiB
Swift

import AdaptiveReplayKit
import Foundation
import Testing
struct AdaptiveReplayTraceParserTests {
private static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000)
private func encode(_ record: AdaptiveRefreshTraceRecord) throws -> String {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(record)
return try #require(String(data: data, encoding: .utf8))
}
@Test
func `parses a well-formed trace and preserves record order`() throws {
let records: [AdaptiveRefreshTraceRecord] = [
.menuOpen(timestamp: Self.referenceNow),
.decision(
timestamp: Self.referenceNow.addingTimeInterval(1),
menuAgeSeconds: 1,
lowPowerModeEnabled: false,
thermalState: .nominal,
reason: "recentInteraction",
delaySeconds: 120),
.refreshCompleted(timestamp: Self.referenceNow.addingTimeInterval(121)),
]
let text = try records.map(self.encode).joined(separator: "\n")
let parsed = try AdaptiveRefreshTraceParser.parse(text)
#expect(parsed.count == 3)
#expect(parsed[0].kind == .menuOpen)
#expect(parsed[1].kind == .decision)
#expect(parsed[1].reason == "recentInteraction")
#expect(parsed[1].delaySeconds == 120.0)
#expect(parsed[2].kind == .refreshCompleted)
}
@Test
func `ignores blank lines between records`() throws {
let record = AdaptiveRefreshTraceRecord.menuOpen(timestamp: Self.referenceNow)
let text = try "\n\(self.encode(record))\n\n"
let parsed = try AdaptiveRefreshTraceParser.parse(text)
#expect(parsed.count == 1)
}
@Test
func `strict parsing accepts multiple CRLF records`() throws {
let records: [AdaptiveRefreshTraceRecord] = [
.menuOpen(timestamp: Self.referenceNow),
.refreshCompleted(timestamp: Self.referenceNow.addingTimeInterval(1)),
]
let text = try records.map(self.encode).joined(separator: "\r\n")
let parsed = try AdaptiveRefreshTraceParser.parse(text)
#expect(parsed.map(\.kind) == [.menuOpen, .refreshCompleted])
}
@Test
func `empty trace parses to zero records`() throws {
let parsed = try AdaptiveRefreshTraceParser.parse("")
#expect(parsed.isEmpty)
}
@Test
func `a malformed line fails the whole parse with a line number`() throws {
let good = try self.encode(.menuOpen(timestamp: Self.referenceNow))
let text = "\(good)\nnot json\n\(good)"
#expect(throws: AdaptiveRefreshTraceParseError.self) {
try AdaptiveRefreshTraceParser.parse(text)
}
do {
_ = try AdaptiveRefreshTraceParser.parse(text)
Issue.record("expected parse to throw")
} catch let error as AdaptiveRefreshTraceParseError {
#expect(error.lineNumber == 2)
#expect(error.content == "not json")
} catch {
Issue.record("unexpected error type: \(error)")
}
}
@Test
func `tolerant parsing skips malformed lines instead of failing`() throws {
let good = try self.encode(.menuOpen(timestamp: Self.referenceNow))
let text = "\(good)\nnot json\n\(good)"
let parsed = AdaptiveRefreshTraceParser.parseTolerantly(text)
#expect(parsed.count == 2)
}
@Test
func `tolerant parsing accepts CRLF and skips only malformed records`() throws {
let menuOpen = try self.encode(.menuOpen(timestamp: Self.referenceNow))
let refresh = try self.encode(.refreshCompleted(timestamp: Self.referenceNow.addingTimeInterval(1)))
let text = [menuOpen, "not json", refresh].joined(separator: "\r\n")
let parsed = AdaptiveRefreshTraceParser.parseTolerantly(text)
#expect(parsed.map(\.kind) == [.menuOpen, .refreshCompleted])
}
/// `timerAdvanced` round-trips its two extra fields (`previousScheduledAt`,
/// `candidateScheduledAt`) and leaves the signal fields (`menuAgeSeconds`,
/// `lowPowerModeEnabled`, `thermalState`) nil, matching the type's field-presence contract.
@Test
func `parses a timerAdvanced record and preserves its schedule fields`() throws {
let record = AdaptiveRefreshTraceRecord.timerAdvanced(
timestamp: Self.referenceNow,
previousScheduledAt: Self.referenceNow.addingTimeInterval(1800),
candidateScheduledAt: Self.referenceNow.addingTimeInterval(120),
reason: "recentInteraction",
delaySeconds: 120)
let text = try self.encode(record)
let parsed = try AdaptiveRefreshTraceParser.parse(text)
#expect(parsed.count == 1)
#expect(parsed[0].kind == .timerAdvanced)
#expect(parsed[0].reason == "recentInteraction")
#expect(parsed[0].delaySeconds == 120.0)
#expect(parsed[0].previousScheduledAt == Self.referenceNow.addingTimeInterval(1800))
#expect(parsed[0].candidateScheduledAt == Self.referenceNow.addingTimeInterval(120))
#expect(parsed[0].menuAgeSeconds == nil)
#expect(parsed[0].lowPowerModeEnabled == nil)
#expect(parsed[0].thermalState == nil)
}
/// A `timerAdvanced` record whose advance had no prior schedule (`previousScheduledAt == nil`)
/// — the "always advance" case `UsageStore.shouldAdvanceAdaptiveTimer` returns for a nil
/// `scheduledAt` — round-trips the nil correctly rather than defaulting to some sentinel date.
@Test
func `a timerAdvanced record with no previous schedule round-trips a nil previousScheduledAt`() throws {
let record = AdaptiveRefreshTraceRecord.timerAdvanced(
timestamp: Self.referenceNow,
previousScheduledAt: nil,
candidateScheduledAt: Self.referenceNow.addingTimeInterval(120),
reason: "recentInteraction",
delaySeconds: 120)
let text = try self.encode(record)
let parsed = try AdaptiveRefreshTraceParser.parse(text)
#expect(parsed[0].previousScheduledAt == nil)
}
/// Backward compatibility: the ~500 pre-existing lines in this machine's live trace were
/// written before `codexActivitySeconds`/`claudeActivitySeconds` existed. A hand-written
/// old-format `decision` line (no activity keys at all) must still decode, with both new
/// fields nil rather than failing to parse.
@Test
func `an old-format decision line without activity fields decodes with nil activity signals`() throws {
let oldFormatLine = """
{"kind":"decision","timestamp":"2026-01-01T00:00:00Z","menuAgeSeconds":30,\
"lowPowerModeEnabled":false,"thermalState":"nominal","reason":"longIdle","delaySeconds":1800}
"""
let parsed = try AdaptiveRefreshTraceParser.parse(oldFormatLine)
#expect(parsed.count == 1)
#expect(parsed[0].reason == "longIdle")
#expect(parsed[0].codexActivitySeconds == nil)
#expect(parsed[0].claudeActivitySeconds == nil)
}
/// A `decision` record carrying both activity signals round-trips them exactly.
@Test
func `a decision record with activity signals round-trips both values`() throws {
let record = AdaptiveRefreshTraceRecord.decision(
timestamp: Self.referenceNow,
menuAgeSeconds: 5,
lowPowerModeEnabled: false,
thermalState: .nominal,
reason: "recentInteraction",
delaySeconds: 120,
codexActivitySeconds: 42,
claudeActivitySeconds: 99)
let text = try self.encode(record)
let parsed = try AdaptiveRefreshTraceParser.parse(text)
#expect(parsed[0].codexActivitySeconds == 42)
#expect(parsed[0].claudeActivitySeconds == 99)
}
/// Encoding must omit nil activity fields rather than emitting explicit `null`s, so old
/// tooling and hand-inspection of a trace stay unsurprised by fields it doesn't expect.
@Test
func `encoding a decision with nil activity signals omits both keys entirely`() throws {
let record = AdaptiveRefreshTraceRecord.decision(
timestamp: Self.referenceNow,
menuAgeSeconds: 5,
lowPowerModeEnabled: false,
thermalState: .nominal,
reason: "recentInteraction",
delaySeconds: 120)
let text = try self.encode(record)
#expect(!text.contains("codexActivitySeconds"))
#expect(!text.contains("claudeActivitySeconds"))
}
/// Backward compatibility for the "B layer" (session duration / transcript bytes /
/// active-transcript count): an old-format line written before those three fields per CLI
/// existed — including one already carrying the earlier "A layer" activity-seconds fields —
/// must still decode, with all six new fields nil.
@Test
func `an old-format decision line without B-layer fields decodes with nil B-layer signals`() throws {
let oldFormatLine = """
{"kind":"decision","timestamp":"2026-01-01T00:00:00Z","menuAgeSeconds":30,\
"lowPowerModeEnabled":false,"thermalState":"nominal","reason":"longIdle","delaySeconds":1800,\
"codexActivitySeconds":42,"claudeActivitySeconds":99}
"""
let parsed = try AdaptiveRefreshTraceParser.parse(oldFormatLine)
#expect(parsed.count == 1)
#expect(parsed[0].codexActivitySeconds == 42)
#expect(parsed[0].claudeActivitySeconds == 99)
#expect(parsed[0].codexSessionDurationSeconds == nil)
#expect(parsed[0].claudeSessionDurationSeconds == nil)
#expect(parsed[0].codexTranscriptBytes == nil)
#expect(parsed[0].claudeTranscriptBytes == nil)
#expect(parsed[0].codexActiveTranscriptCount == nil)
#expect(parsed[0].claudeActiveTranscriptCount == nil)
}
/// A trace mixing a pre-B-layer line, a pre-A-layer (original phase 1) line, and a full
/// current-format line all parse together — the parser never requires every line in a trace
/// to share the same schema vintage.
@Test
func `a trace mixing old and new format decision lines parses every line`() throws {
let phase1Line = """
{"kind":"decision","timestamp":"2026-01-01T00:00:00Z","reason":"longIdle","delaySeconds":1800}
"""
let aLayerOnlyLine = """
{"kind":"decision","timestamp":"2026-01-02T00:00:00Z","reason":"warm","delaySeconds":300,\
"codexActivitySeconds":10}
"""
let currentLine = try self.encode(.decision(
timestamp: Self.referenceNow,
menuAgeSeconds: nil,
lowPowerModeEnabled: false,
thermalState: .nominal,
reason: "recentInteraction",
delaySeconds: 120,
codexActivitySeconds: 1,
claudeActivitySeconds: 2,
codexSessionDurationSeconds: 3,
claudeSessionDurationSeconds: 4,
codexTranscriptBytes: 5,
claudeTranscriptBytes: 6,
codexActiveTranscriptCount: 7,
claudeActiveTranscriptCount: 8))
let text = [phase1Line, aLayerOnlyLine, currentLine].joined(separator: "\n")
let parsed = try AdaptiveRefreshTraceParser.parse(text)
#expect(parsed.count == 3)
#expect(parsed[0].codexActivitySeconds == nil)
#expect(parsed[1].codexActivitySeconds == 10)
#expect(parsed[1].codexSessionDurationSeconds == nil)
#expect(parsed[2].codexSessionDurationSeconds == 3)
#expect(parsed[2].claudeActiveTranscriptCount == 8)
}
/// A `decision` record carrying all six B-layer fields round-trips them exactly.
@Test
func `a decision record with B-layer fields round-trips all six values`() throws {
let record = AdaptiveRefreshTraceRecord.decision(
timestamp: Self.referenceNow,
menuAgeSeconds: 5,
lowPowerModeEnabled: false,
thermalState: .nominal,
reason: "recentInteraction",
delaySeconds: 120,
codexSessionDurationSeconds: 600,
claudeSessionDurationSeconds: 900,
codexTranscriptBytes: 12345,
claudeTranscriptBytes: 67890,
codexActiveTranscriptCount: 2,
claudeActiveTranscriptCount: 4)
let text = try self.encode(record)
let parsed = try AdaptiveRefreshTraceParser.parse(text)
#expect(parsed[0].codexSessionDurationSeconds == 600)
#expect(parsed[0].claudeSessionDurationSeconds == 900)
#expect(parsed[0].codexTranscriptBytes == 12345)
#expect(parsed[0].claudeTranscriptBytes == 67890)
#expect(parsed[0].codexActiveTranscriptCount == 2)
#expect(parsed[0].claudeActiveTranscriptCount == 4)
}
/// Encoding must omit nil B-layer fields rather than emitting explicit `null`s, matching the
/// A-layer's contract.
@Test
func `encoding a decision with nil B-layer fields omits all six keys entirely`() throws {
let record = AdaptiveRefreshTraceRecord.decision(
timestamp: Self.referenceNow,
menuAgeSeconds: 5,
lowPowerModeEnabled: false,
thermalState: .nominal,
reason: "recentInteraction",
delaySeconds: 120)
let text = try self.encode(record)
#expect(!text.contains("codexSessionDurationSeconds"))
#expect(!text.contains("claudeSessionDurationSeconds"))
#expect(!text.contains("codexTranscriptBytes"))
#expect(!text.contains("claudeTranscriptBytes"))
#expect(!text.contains("codexActiveTranscriptCount"))
#expect(!text.contains("claudeActiveTranscriptCount"))
}
}