fix: 处理 review 反馈并消除取消测试的调度抖动

- 风扇曾报告状态键时,偶发读取失败沿用原起步窗口
- 显示器亮度百分比使用 locale 百分比格式
- 显示器行按连接身份区分,换屏后丢弃旧拖动草稿
- 取消测试在采样线程内记录返回时刻,排除 await 恢复延迟
This commit is contained in:
ysicing authored and 缘生 committed 2026-10-04 21:52:14 +08:00
1 parent a7b894f386
commit 6782efa805
4 files changed
+30 -6

No files matched your search

@@ -47,9 +47,15 @@ extension Array where Element == FanState {
/// 超时后保留真实的 0 RPM,直到风扇转动或起步条件消失才允许开始新的窗口。
struct FanStartupTracker {
private var startedAt: [Int: TimeInterval] = [:]
/// 曾读到状态键的风扇;之后读不到视为偶发失败,沿用原窗口而不是改用手动目标推断。
private var reportsStatus: Set<Int> = []
mutating func update(id: Int, current: Double, target: Double, isManual: Bool,
status: Double?, now: TimeInterval) -> Bool {
if status != nil { reportsStatus.insert(id) }
if current == 0, status == nil, reportsStatus.contains(id) {
return startedAt[id].map { now - $0 < 10 } ?? false
}
let starting = status.map { $0 == 1 } ?? (isManual && target.isFinite && target > 0)
guard current == 0, starting else {
startedAt[id] = nil
@@ -64,6 +70,7 @@ struct FanStartupTracker {
// nil 是读取失败,不能当作确认无风扇,否则会重新开始已超时的提示。
guard let fanCount else { return }
startedAt = startedAt.filter { (0..<fanCount).contains($0.key) }
reportsStatus = reportsStatus.filter { (0..<fanCount).contains($0) }
}
}
@@ -16,7 +16,8 @@ struct DisplayDetails: View {
if model.displays.catalog.isEmpty {
Text(tr("没有检测到显示器")).dsFont(.sm).foregroundStyle(DS.Palette.textSecondary)
}
ForEach(model.displays.catalog) { display in
// CG 编号会复用,按连接身份区分行,换屏后丢弃旧行的拖动草稿,避免写到新显示器。
ForEach(model.displays.catalog, id: \.target) { display in
VStack(alignment: .leading, spacing: DS.Space.s2) {
HStack {
Label(display.name, systemImage: display.isBuiltIn ? "laptopcomputer" : "display")
@@ -62,7 +63,7 @@ private struct DisplayControlRow: View {
Text(control.title).dsFont(.sm)
Spacer()
if let value {
Text(verbatim: ((editing || submitting) ? (draft ?? value.percent) : value.percent).formatted(.number.precision(.fractionLength(0)).locale(L10n.locale)) + "%")
Text(verbatim: (((editing || submitting) ? (draft ?? value.percent) : value.percent) / 100).formatted(.percent.precision(.fractionLength(0)).locale(L10n.locale)))
.dsFont(.sm, weight: .medium).monospacedDigit()
} else {
Text(status).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
@@ -32,7 +32,7 @@ struct FanStartupTests {
#expect(!result7)
let result8 = tracker.update(id: 0, current: 0, target: 0, isManual: true, status: nil, now: 2)
#expect(!result8)
let result9 = tracker.update(id: 0, current: 0, target: 2000, isManual: true, status: 3, now: 3)
let result9 = tracker.update(id: 1, current: 0, target: 2000, isManual: true, status: 3, now: 3)
#expect(!result9)
let result10 = tracker.update(id: 0, current: 0, target: 2000, isManual: true, status: nil, now: 4)
#expect(result10)
@@ -58,6 +58,20 @@ struct FanStartupTests {
#expect(result17)
}
@Test func transientStatusFailureKeepsTheFirmwareWindow() {
var tracker = FanStartupTracker()
let initiallyStarting = tracker.update(id: 0, current: 0, target: 0, isManual: false, status: 1, now: 0)
#expect(initiallyStarting)
let duringFailure = tracker.update(id: 0, current: 0, target: 0, isManual: false, status: nil, now: 5)
#expect(duringFailure)
let expiredDuringFailure = tracker.update(id: 0, current: 0, target: 0, isManual: false, status: nil, now: 11)
#expect(!expiredDuringFailure)
let afterRecovery = tracker.update(id: 0, current: 0, target: 0, isManual: false, status: 1, now: 12)
#expect(!afterRecovery)
let failureWithManualTarget = tracker.update(id: 0, current: 0, target: 2000, isManual: true, status: nil, now: 13)
#expect(!failureWithManualTarget)
}
@Test func failedFanCountDoesNotRestartAnExpiredWindow() {
var tracker = FanStartupTracker()
let initiallyStarting = tracker.update(id: 0, current: 0, target: 2000, isManual: true, status: nil, now: 0)
@@ -208,12 +208,14 @@ struct LiveSamplerTests {
let marker = FileManager.default.temporaryDirectory.appending(path: "xstats-process-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: marker) }
let clock = ContinuousClock()
// 在采样线程内记录返回时刻,避免并发线程池繁忙时 await 恢复延迟计入取消耗时。
let task = Task.detached {
NetworkProcessSampler.run(
let output = NetworkProcessSampler.run(
path: "/bin/sh",
arguments: ["-c", "touch \"$1\"; sleep 5", "xstats-test", marker.path],
timeout: 5
)
return (output, clock.now)
}
let launchDeadline = clock.now + .seconds(5)
@@ -223,11 +225,11 @@ struct LiveSamplerTests {
try #require(FileManager.default.fileExists(atPath: marker.path))
let cancelled = clock.now
task.cancel()
let output = await task.value
let (output, returned) = await task.value
#expect(output == nil)
// TERM、KILL 和管道排空可各等待 0.5 秒;留出 CI 调度余量,仍须早于 5 秒命令完成。
#expect(cancelled.duration(to: clock.now) < .seconds(3))
#expect(cancelled.duration(to: returned) < .seconds(3))
}
@Test func parsesCloudflareTrace() {