09a5957c6d
One stat model everywhere (design/stats-unification.md): four measurement points (capture/received/decoded/displayed), three stages that tile the interval exactly, and a HUD that shows the addition explicitly — end-to-end 14.2 ms p50 · 19.8 p95 · capture→on-glass = host+network 9.8 + decode 2.1 + display 2.3 replacing each client's ad-hoc mix of overlapping absolutes (the Apple HUD's three arrow lines that looked sequential but weren't), mean-vs-median decode times (Windows/Linux), missing same-host-clock flags (Windows/Linux), and three different names for the same capture→received measurement (probe's "reassembled", Apple/Android's "client", Windows/Linux's post-decode "lat"). Per client: Apple threads receivedNs through the VT decode via the frame refcon bit pattern so the decode stage exists at all (stage-1 fallback honestly degrades to a capture→received headline); Windows carries FrameTimes through the existing frame channel to the render thread and adds e2e p50/p95 post-Present; Linux stamps received at AU pop and rides decoded_ns on DecodedFrame to the paintable-set site; Android pairs receipt stamps with MediaCodec output buffers via the codec's pts round-trip (JNI stats array 14→16 doubles, indexes 0-13 unchanged). fps now uniformly counts received AUs; lost/(received+lost) per window, hidden at zero. docs-site gains "Understanding the Stats Overlay": what each line means, why the equation only approximately sums (percentiles), and a line-by-line Moonlight/Sunshine matrix — including that Moonlight has no end-to-end number and its "network latency" is an ENet control RTT, so punktfunk's headline must not be compared against any single Moonlight line. Verified here: linux client + probe + core check/clippy/fmt green, android native cargo-ndk arm64 check green. Pending: Windows CI + on-glass, swift test on the mac, on-device Android. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
75 lines
3.2 KiB
Swift
75 lines
3.2 KiB
Swift
// Unit tests for LatencyMeter (one instance per unified-stats stage — see
|
|
// design/stats-unification.md): percentiles, the skew-corrected flag, reset-on-drain, the
|
|
// absurd-value guard, and the explicit-instant stage form (record(ptsNs:atNs:offsetNs:), used for
|
|
// the client-local decode/display stages and the at-present end-to-end stamp). Receipt-path
|
|
// latencies are constructed by stamping a pts a known interval in the past, so the result is that
|
|
// interval plus the (tiny) clock advance between reads — asserted with tolerance; the explicit
|
|
// form is exact.
|
|
|
|
import Foundation
|
|
import XCTest
|
|
|
|
@testable import PunktfunkKit
|
|
|
|
final class LatencyMeterTests: XCTestCase {
|
|
private func nowRealtimeNs() -> UInt64 {
|
|
var ts = timespec()
|
|
clock_gettime(CLOCK_REALTIME, &ts)
|
|
return UInt64(ts.tv_sec) * 1_000_000_000 + UInt64(ts.tv_nsec)
|
|
}
|
|
|
|
func testEmptyDrainIsNil() {
|
|
XCTAssertNil(LatencyMeter().drain())
|
|
}
|
|
|
|
func testRecordsPercentilesAndResets() {
|
|
let m = LatencyMeter()
|
|
let now = nowRealtimeNs()
|
|
// Each frame "captured" 5 ms ago, no skew offset → latency ≈ 5 ms.
|
|
for _ in 0..<50 { m.record(ptsNs: now - 5_000_000, offsetNs: 0) }
|
|
guard let s = m.drain() else { return XCTFail("expected samples") }
|
|
XCTAssertEqual(s.count, 50)
|
|
XCTAssertFalse(s.skewCorrected, "offset 0 ⇒ not skew-corrected")
|
|
XCTAssertEqual(s.p50Ms, 5.0, accuracy: 2.0)
|
|
XCTAssertGreaterThanOrEqual(s.p99Ms, s.p50Ms)
|
|
XCTAssertNil(m.drain(), "drain resets the window")
|
|
}
|
|
|
|
func testSkewCorrectedFlagSetByNonZeroOffset() {
|
|
let m = LatencyMeter()
|
|
let now = nowRealtimeNs()
|
|
m.record(ptsNs: now - 1_000_000, offsetNs: 250_000) // 1 ms ago, +0.25 ms offset
|
|
XCTAssertEqual(m.drain()?.skewCorrected, true)
|
|
}
|
|
|
|
func testExplicitStageRecordIsExact() {
|
|
let m = LatencyMeter()
|
|
// A client-local stage (decode: received→decoded) — start instant as ptsNs, offset 0.
|
|
let receivedNs: Int64 = 1_000_000_000_000
|
|
m.record(ptsNs: UInt64(receivedNs), atNs: receivedNs + 3_000_000, offsetNs: 0)
|
|
guard let s = m.drain() else { return XCTFail("expected a sample") }
|
|
XCTAssertEqual(s.count, 1)
|
|
XCTAssertEqual(s.p50Ms, 3.0, "explicit instants make the sample exact")
|
|
XCTAssertFalse(s.skewCorrected, "local stages record with offset 0")
|
|
}
|
|
|
|
func testExplicitStageDropsNonPositiveInterval() {
|
|
let m = LatencyMeter()
|
|
// A stage whose start stamp is missing (0) or after its end must not pollute the window.
|
|
let decodedNs: Int64 = 1_000_000_000_000
|
|
m.record(ptsNs: 0, atNs: decodedNs, offsetNs: 0) // "start unknown" → > 10 s → dropped
|
|
m.record(ptsNs: UInt64(decodedNs + 1), atNs: decodedNs, offsetNs: 0) // negative → dropped
|
|
XCTAssertNil(m.drain())
|
|
}
|
|
|
|
func testDropsAbsurdValues() {
|
|
let m = LatencyMeter()
|
|
let now = nowRealtimeNs()
|
|
// pts 1 s in the future → negative latency → dropped.
|
|
m.record(ptsNs: now + 1_000_000_000, offsetNs: 0)
|
|
// pts absurdly far in the past → > 10 s latency → dropped.
|
|
m.record(ptsNs: now - 20_000_000_000, offsetNs: 0)
|
|
XCTAssertNil(m.drain())
|
|
}
|
|
}
|