修复子代理与 fork 会话的 Token 全量丢失:原判定要求 task_started 带 started_at 且不早于会话创建时间才结束回放,但子代理的 started_at 是父级 回合的开始时间,天然早于子会话文件,新版 Codex 更是完全不发该字段。改为 首个 task_started 无条件结束回放——实测 661 个真实 rollout 中没有任何一个 的 token_count 出现在它之前。本机语料 A/B:Token 13.60G → 14.53G(+6.8%), 用量记录 68997 → 73566。 其他修复: - 两个 Provider 钉到专用串行执行器,冷扫描不再占用协作线程池。把池限制 成一条线程实测,修复前并行的每秒采样在 43 秒扫描期间一次都没能推进。 - 按日期与模型聚合并定序输出,行数 701 → 272 且 id 唯一;顺带修掉冷扫描 与检查点恢复顺序不一致导致每次刷新都产生"不相等"报告的问题。 - 缓存行加剪枝,覆盖归档搬走的 rollout、删除的项目目录与旧解析命名空间; 检查点仅在末行未完时存第二份状态,Claude 事件按保留窗口裁剪。 - 复用 ISO8601 解析器,不再逐行创建。 - 数字与日期改跟随应用语言,弹窗与菜单栏共用同一套 Token 缩写。 - 窄弹窗热力图按设计尺寸铺开后横向滚动,此前被钉在面板宽度上永不溢出。 - 打开菜单栏项一并开启扫描,并为无数据状态补上悬停文案。 - 删除未被应用构造的配额 Provider、凭据读取与响应映射(约 330 行)及其 全链路管线,并清掉 37 条孤立译文 × 8 个语言文件。
117 lines
6.3 KiB
Swift
117 lines
6.3 KiB
Swift
// Copyright (C) 2026 ysicing
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import Foundation
|
|
import Testing
|
|
@testable import AIUsage
|
|
|
|
@Suite struct UsageScanStoreTests {
|
|
private struct Sum: Codable { var total = 0 }
|
|
private func scan(_ store: UsageScanStore, _ file: URL, source: String = "test") throws -> Int {
|
|
try store.scan(url: file, source: source, initial: Sum()) { state, line in
|
|
state.total += Int(String(decoding: line, as: UTF8.self)) ?? 0
|
|
}.total
|
|
}
|
|
|
|
@Test func restartUsesDatabaseAndAppendOnlyReadsSuffix() throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let db = root.appendingPathComponent("cache.sqlite")
|
|
let file = root.appendingPathComponent("usage.jsonl")
|
|
var first: UsageScanStore? = try UsageScanStore(url: db)
|
|
try Data(String(repeating: "1\n", count: 100_000).utf8).write(to: file)
|
|
#expect(try scan(first!, file) == 100_000)
|
|
first = nil
|
|
let restarted = try UsageScanStore(url: db)
|
|
#expect(try scan(restarted, file) == 100_000)
|
|
#expect(restarted.bytesRead == 0)
|
|
let handle = try FileHandle(forWritingTo: file)
|
|
try handle.seekToEnd(); try handle.write(contentsOf: Data("2\n".utf8)); try handle.close()
|
|
#expect(try scan(restarted, file) == 100_002)
|
|
#expect(restarted.bytesRead < 20_000)
|
|
}
|
|
|
|
@Test func unfinishedLastLineDoesNotDoubleCountAfterAppend() throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let db = root.appendingPathComponent("cache.sqlite")
|
|
let file = root.appendingPathComponent("usage.jsonl")
|
|
let store = try UsageScanStore(url: db)
|
|
try Data("1\n2".utf8).write(to: file)
|
|
#expect(try scan(store, file) == 3)
|
|
let restarted = try UsageScanStore(url: db)
|
|
let handle = try FileHandle(forWritingTo: file)
|
|
try handle.seekToEnd(); try handle.write(contentsOf: Data("\n3\n".utf8)); try handle.close()
|
|
#expect(try scan(restarted, file) == 6)
|
|
}
|
|
|
|
@Test func truncationAndReplacementRebuildOnlyThatFile() throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let store = try UsageScanStore(url: root.appendingPathComponent("cache.sqlite"))
|
|
let file = root.appendingPathComponent("usage.jsonl")
|
|
try Data("100\n200\n".utf8).write(to: file)
|
|
#expect(try scan(store, file) == 300)
|
|
try Data("3\n".utf8).write(to: file)
|
|
#expect(try scan(store, file) == 3)
|
|
try Data("5\n6\n7\n".utf8).write(to: file, options: .atomic)
|
|
#expect(try scan(store, file) == 18)
|
|
}
|
|
|
|
/// 归档会改变 rollout 路径、项目目录会被删掉、解析口径升级会换命名空间。
|
|
/// 这些行都不会再被扫到,留着就是让缓存库连同完整解析状态一起无限增长。
|
|
@Test func pruneDropsVanishedFilesAndOldNamespacesButKeepsOtherSources() throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
let store = try UsageScanStore(url: root.appendingPathComponent("cache.sqlite"))
|
|
|
|
var files: [String: URL] = [:]
|
|
for name in ["kept", "archived", "legacy", "other"] {
|
|
let url = root.appendingPathComponent("\(name).jsonl")
|
|
try Data("7\n".utf8).write(to: url)
|
|
files[name] = url
|
|
}
|
|
#expect(try scan(store, files["kept"]!, source: "codex-v2") == 7)
|
|
#expect(try scan(store, files["archived"]!, source: "codex-v2") == 7)
|
|
#expect(try scan(store, files["legacy"]!, source: "codex-v1") == 7)
|
|
#expect(try scan(store, files["other"]!, source: "claude-v2") == 7)
|
|
|
|
let live = Set([files["kept"]!.resolvingSymlinksInPath().path])
|
|
try store.prune(family: "codex-", source: "codex-v2", keeping: live)
|
|
|
|
// 还在的文件命中缓存,不重新读盘
|
|
#expect(try scan(store, files["kept"]!, source: "codex-v2") == 7)
|
|
#expect(store.bytesRead == 0)
|
|
// 被剪掉的两行要重新读盘
|
|
#expect(try scan(store, files["archived"]!, source: "codex-v2") == 7)
|
|
#expect(store.bytesRead > 0)
|
|
#expect(try scan(store, files["legacy"]!, source: "codex-v1") == 7)
|
|
#expect(store.bytesRead > 0)
|
|
// 另一个 Provider 的行不受影响
|
|
#expect(try scan(store, files["other"]!, source: "claude-v2") == 7)
|
|
#expect(store.bytesRead == 0)
|
|
}
|
|
|
|
@Test func codexParserRestoresModelAndFractionalTimestampBaseline() throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let db = root.appendingPathComponent("cache.sqlite")
|
|
let file = root.appendingPathComponent("usage.jsonl")
|
|
let first = try UsageScanStore(url: db)
|
|
let context = #"{"type":"turn_context","payload":{"model":"model-a"}}"# + "\n"
|
|
func event(_ n: Int) -> String {
|
|
"{\"timestamp\":\"2026-09-22T10:00:00.123Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"info\":{\"total_token_usage\":{\"input_tokens\":\(n),\"output_tokens\":1}}}}\n"
|
|
}
|
|
try Data((context + event(100)).utf8).write(to: file)
|
|
_ = try first.scan(url: file, source: "codex", initial: CodexLocalLogParser()) { $0.consume($1) }
|
|
let handle = try FileHandle(forWritingTo: file)
|
|
try handle.seekToEnd(); try handle.write(contentsOf: Data(event(120).utf8)); try handle.close()
|
|
let restarted = try UsageScanStore(url: db)
|
|
let result = try restarted.scan(url: file, source: "codex", initial: CodexLocalLogParser()) { $0.consume($1) }
|
|
#expect(LocalUsageReport.total(Array(result.rows.values)).total == 121)
|
|
#expect(LocalUsageReport.total(Array(result.rows.values)).records == 2)
|
|
#expect(result.rows.values.first?.model == "model-a")
|
|
}
|
|
}
|