,
): void {
@@ -1453,6 +1463,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
stream,
profile: qualitySettingsRef.current,
videoCodec: videoCodecRef.current,
+ natPredictionEnabled: natPredictionEnabledRef.current,
});
}
@@ -1529,6 +1540,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
},
},
videoCodecRef.current,
+ undefined,
+ natPredictionEnabledRef.current,
);
peersRef.current.set(peerId, peer);
let started: boolean;
@@ -3820,6 +3833,14 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
note={t("host.advanced.route.peerOnlyHint")}
hint="hint-route-p2p"
/>
+
diff --git a/src/client/webrtc/host-peer.ts b/src/client/webrtc/host-peer.ts
index a8b4d8f..8e3c795 100644
--- a/src/client/webrtc/host-peer.ts
+++ b/src/client/webrtc/host-peer.ts
@@ -37,13 +37,14 @@ import {
type BrowserVideoCodecPreference,
VP8_ONLY_VIDEO_CODEC,
} from "./video-codec";
+import {
+ iceServersWithNatPrediction,
+ NatPredictionCandidateBatch,
+ type SignalCandidate,
+} from "./nat-prediction";
const MAX_PENDING_CANDIDATES = 64;
type PeerIceConfig = Pick;
-type SignalCandidate = Extract<
- SignalPayload,
- { kind: "candidate" }
->["candidate"];
interface HostPeerEvents {
sendSignal: (peerId: string, payload: SignalPayload) => boolean;
@@ -55,6 +56,10 @@ export class HostPeer {
private readonly connection: RTCPeerConnection;
private readonly pendingCandidates: SignalCandidate[] = [];
+ private readonly natPredictionEnabled: boolean;
+ private natCandidateBatch: NatPredictionCandidateBatch | null = null;
+ private natCandidateUsernameFragment: string | null = null;
+ private natCandidateEndSent = false;
private statsAccumulator = createStatsAccumulator();
private senderVideoTrack: MediaStreamTrack | null;
private replacementVideoTrack: MediaStreamTrack | null = null;
@@ -94,8 +99,10 @@ export class HostPeer {
private readonly videoCodec: BrowserVideoCodecPreference =
VP8_ONLY_VIDEO_CODEC,
connectionId = createOpaqueId(),
+ natPredictionEnabled = false,
) {
this.connectionId = connectionId;
+ this.natPredictionEnabled = natPredictionEnabled;
const sourceVideoTrack = stream.getVideoTracks()[0] ?? null;
this.paused = sourceVideoTrack?.enabled === false;
this.senderVideoTrack = sourceVideoTrack
@@ -103,7 +110,10 @@ export class HostPeer {
: null;
this.startupVideoProfilePending = needsStartupVideoProfile(desiredProfile);
this.connection = new RTCPeerConnection({
- iceServers: iceConfig.iceServers,
+ iceServers: iceServersWithNatPrediction(
+ iceConfig.iceServers,
+ this.natPredictionEnabled,
+ ),
});
this.snapshot = {
peerId,
@@ -399,7 +409,10 @@ export class HostPeer {
}
try {
this.connection.setConfiguration({
- iceServers: iceConfig.iceServers,
+ iceServers: iceServersWithNatPrediction(
+ iceConfig.iceServers,
+ this.natPredictionEnabled,
+ ),
});
} catch (error) {
this.setError(error, say("host.err.createConnection"));
@@ -418,24 +431,61 @@ export class HostPeer {
this.statsTimer = null;
}
this.connection.close();
+ this.natCandidateBatch?.discard();
+ this.natCandidateBatch = null;
+ this.natCandidateUsernameFragment = null;
+ this.natCandidateEndSent = false;
this.senderVideoTrack?.stop();
this.senderVideoTrack = null;
}
private bindConnectionEvents(): void {
this.connection.addEventListener("icecandidate", (event) => {
- this.events.sendSignal(this.peerId, {
- kind: "candidate",
- connectionId: this.connectionId,
- candidate: event.candidate
- ? {
- candidate: event.candidate.candidate,
- sdpMid: event.candidate.sdpMid,
- sdpMLineIndex: event.candidate.sdpMLineIndex,
- usernameFragment: event.candidate.usernameFragment,
- }
- : null,
- });
+ const candidate = event.candidate
+ ? {
+ candidate: event.candidate.candidate,
+ sdpMid: event.candidate.sdpMid,
+ sdpMLineIndex: event.candidate.sdpMLineIndex,
+ usernameFragment: event.candidate.usernameFragment,
+ }
+ : null;
+ if (!this.natPredictionEnabled) {
+ this.sendIceCandidate(candidate);
+ return;
+ }
+ if (candidate === null) {
+ this.completeNatCandidateBatch();
+ if (!this.natCandidateEndSent) {
+ this.sendIceCandidate(null);
+ this.natCandidateEndSent = true;
+ }
+ return;
+ }
+ if (
+ this.natCandidateBatch &&
+ this.natCandidateUsernameFragment &&
+ candidate.usernameFragment &&
+ candidate.usernameFragment !== this.natCandidateUsernameFragment
+ ) {
+ this.natCandidateBatch.complete();
+ this.natCandidateBatch = null;
+ }
+ if (!this.natCandidateBatch) {
+ this.natCandidateUsernameFragment = candidate.usernameFragment ?? null;
+ this.natCandidateEndSent = false;
+ this.natCandidateBatch = new NatPredictionCandidateBatch((next) =>
+ this.sendIceCandidate(next),
+ );
+ }
+ this.natCandidateBatch.add(candidate);
+ });
+ this.connection.addEventListener("icegatheringstatechange", () => {
+ if (
+ this.natPredictionEnabled &&
+ this.connection.iceGatheringState === "complete"
+ ) {
+ this.completeNatCandidateBatch();
+ }
});
this.connection.addEventListener("connectionstatechange", () => {
const state = this.connection.connectionState;
@@ -457,6 +507,23 @@ export class HostPeer {
this.connection.addEventListener("iceconnectionstatechange", () => this.emit());
}
+ private sendIceCandidate(candidate: SignalCandidate | null): void {
+ this.events.sendSignal(this.peerId, {
+ kind: "candidate",
+ connectionId: this.connectionId,
+ candidate,
+ });
+ }
+
+ private completeNatCandidateBatch(): void {
+ if (this.natCandidateBatch) {
+ this.natCandidateBatch.complete();
+ this.natCandidateBatch = null;
+ this.natCandidateUsernameFragment = null;
+ this.natCandidateEndSent = true;
+ }
+ }
+
private enqueueSenderMutation(operation: () => Promise): Promise {
const result = this.senderMutationTail.then(operation);
this.senderMutationTail = result.then(
diff --git a/src/client/webrtc/nat-prediction.ts b/src/client/webrtc/nat-prediction.ts
new file mode 100644
index 0000000..9e9b758
--- /dev/null
+++ b/src/client/webrtc/nat-prediction.ts
@@ -0,0 +1,300 @@
+import type { SignalPayload } from "../../shared/protocol";
+
+export type SignalCandidate = Extract<
+ SignalPayload,
+ { kind: "candidate" }
+>["candidate"];
+
+const BASE_STUN_PORT = 3478;
+const AUXILIARY_STUN_PORTS = [3479, 3480] as const;
+const MIN_PREDICTABLE_PORT = 1;
+const MAX_PREDICTABLE_PORT = 65_535;
+
+/** Keep the experiment bounded so normal ICE candidates retain their budget. */
+export const NAT_PREDICTION_STEPS = 4;
+export const MAX_NAT_PREDICTION_CANDIDATES = NAT_PREDICTION_STEPS * 2;
+
+interface SrflxObservation {
+ candidate: SignalCandidate;
+ fields: string[];
+ address: string;
+ port: number;
+ groupKey: string;
+}
+
+function urlsOf(server: RTCIceServer): readonly string[] {
+ return Array.isArray(server.urls) ? server.urls : [server.urls];
+}
+
+function cloneIceServer(server: RTCIceServer): RTCIceServer {
+ return {
+ ...server,
+ urls: Array.isArray(server.urls) ? [...server.urls] : server.urls,
+ };
+}
+
+function auxiliaryUrlsFor(url: string): readonly string[] {
+ if (!/^stun:/i.test(url)) {
+ return [];
+ }
+ const authority = url.slice(url.indexOf(":") + 1);
+ let parsed: URL;
+ try {
+ parsed = new URL(`http://${authority}`);
+ } catch {
+ return [];
+ }
+ if (
+ !parsed.hostname ||
+ parsed.username ||
+ parsed.password ||
+ parsed.pathname !== "/" ||
+ parsed.search ||
+ parsed.hash
+ ) {
+ return [];
+ }
+ const port = parsed.port ? Number(parsed.port) : BASE_STUN_PORT;
+ if (port !== BASE_STUN_PORT) {
+ return [];
+ }
+ const hostname = parsed.hostname.startsWith("[")
+ ? parsed.hostname
+ : parsed.hostname.includes(":")
+ ? `[${parsed.hostname}]`
+ : parsed.hostname;
+ return AUXILIARY_STUN_PORTS.map(
+ (auxiliaryPort) => `stun:${hostname}:${auxiliaryPort}`,
+ );
+}
+
+/**
+ * Add the two optional same-host survey listeners without changing the
+ * server-provided STUN list. The caller opts into this per connection.
+ */
+export function iceServersWithNatPrediction(
+ iceServers: readonly RTCIceServer[] | undefined,
+ enabled: boolean,
+): RTCIceServer[] {
+ const base = (iceServers ?? []).map(cloneIceServer);
+ if (!enabled) {
+ return base;
+ }
+
+ const additions = new Set();
+ const existing = new Set(
+ (iceServers ?? []).flatMap((server) => urlsOf(server)),
+ );
+ for (const server of iceServers ?? []) {
+ for (const url of urlsOf(server)) {
+ for (const auxiliaryUrl of auxiliaryUrlsFor(url)) {
+ if (!existing.has(auxiliaryUrl)) {
+ additions.add(auxiliaryUrl);
+ }
+ }
+ if (additions.size >= AUXILIARY_STUN_PORTS.length) {
+ break;
+ }
+ }
+ if (additions.size >= AUXILIARY_STUN_PORTS.length) {
+ break;
+ }
+ }
+
+ return [
+ ...base,
+ ...Array.from(additions, (url) => ({ urls: url })),
+ ];
+}
+
+function parseSrflxCandidate(
+ candidate: SignalCandidate,
+): SrflxObservation | null {
+ if (!candidate || !candidate.candidate.trim()) {
+ return null;
+ }
+ const fields = candidate.candidate.trim().split(/\s+/);
+ const typeIndex = fields.findIndex(
+ (field) => field.toLowerCase() === "typ",
+ );
+ if (
+ fields.length < 8 ||
+ !fields[0]?.toLowerCase().startsWith("candidate:") ||
+ fields[2]?.toLowerCase() !== "udp" ||
+ typeIndex < 0 ||
+ fields[typeIndex + 1]?.toLowerCase() !== "srflx"
+ ) {
+ return null;
+ }
+ const address = fields[4];
+ const port = Number(fields[5]);
+ if (
+ !address ||
+ !Number.isInteger(port) ||
+ port < MIN_PREDICTABLE_PORT ||
+ port > MAX_PREDICTABLE_PORT
+ ) {
+ return null;
+ }
+ const groupKey = [
+ candidate.sdpMid ?? "",
+ candidate.sdpMLineIndex ?? "",
+ address,
+ ].join("/");
+ return { candidate, fields, address, port, groupKey };
+}
+
+function distinctObservations(
+ observations: readonly SrflxObservation[],
+): SrflxObservation[] {
+ const seen = new Set();
+ return observations.filter((observation) => {
+ const key = `${observation.groupKey}/${observation.port}`;
+ if (seen.has(key)) {
+ return false;
+ }
+ seen.add(key);
+ return true;
+ });
+}
+
+function stableStep(observations: readonly SrflxObservation[]): number | null {
+ if (observations.length < 3) {
+ return null;
+ }
+ const sorted = [...observations].sort((left, right) => left.port - right.port);
+ const step = sorted[1]!.port - sorted[0]!.port;
+ if (
+ step <= 0 ||
+ sorted.some(
+ (observation, index) =>
+ index > 0 && observation.port - sorted[index - 1]!.port !== step,
+ )
+ ) {
+ return null;
+ }
+ return step;
+}
+
+function predictedFrom(
+ anchor: SrflxObservation,
+ step: number,
+ occupiedPorts: ReadonlySet,
+): SignalCandidate[] {
+ const predicted: SignalCandidate[] = [];
+ const seenPorts = new Set(occupiedPorts);
+ for (const direction of [1, -1] as const) {
+ for (let index = 1; index <= NAT_PREDICTION_STEPS; index += 1) {
+ const port = anchor.port + direction * step * index;
+ if (port < MIN_PREDICTABLE_PORT || port > MAX_PREDICTABLE_PORT) {
+ continue;
+ }
+ if (seenPorts.has(port)) {
+ continue;
+ }
+ seenPorts.add(port);
+ const fields = [...anchor.fields];
+ fields[0] = `candidate:s${direction > 0 ? "p" : "m"}${index}`;
+ fields[5] = String(port);
+ predicted.push({
+ ...anchor.candidate,
+ candidate: fields.join(" "),
+ });
+ }
+ }
+ return predicted;
+}
+
+/**
+ * Return a bounded, connection-local prediction only for a clear arithmetic
+ * srflx shape. No participant classification or candidate rejection happens
+ * here; callers append the result to the ordinary ICE candidate set.
+ */
+export function predictSrflxCandidates(
+ candidates: readonly SignalCandidate[],
+): SignalCandidate[] {
+ const groups = new Map();
+ for (const candidate of candidates) {
+ const observation = parseSrflxCandidate(candidate);
+ if (!observation) {
+ continue;
+ }
+ const group = groups.get(observation.groupKey) ?? [];
+ group.push(observation);
+ groups.set(observation.groupKey, group);
+ }
+
+ for (const group of groups.values()) {
+ const distinct = distinctObservations(group);
+ const step = stableStep(distinct);
+ if (step === null) {
+ continue;
+ }
+ const anchor = distinct.at(-1)!;
+ return predictedFrom(
+ anchor,
+ step,
+ new Set(distinct.map((observation) => observation.port)),
+ ).slice(0, MAX_NAT_PREDICTION_CANDIDATES);
+ }
+ return [];
+}
+
+/**
+ * Holds only srflx candidates until the mapping shape is known, then emits
+ * predictions before the held ordinary candidates. Non-srflx candidates keep
+ * their normal trickle timing. A new instance belongs to one ICE generation.
+ */
+export class NatPredictionCandidateBatch {
+ private readonly pendingSrflx: SignalCandidate[] = [];
+ private predictionsSent = false;
+ private completed = false;
+
+ constructor(private readonly send: (candidate: SignalCandidate | null) => void) {}
+
+ add(candidate: SignalCandidate): void {
+ if (this.completed) {
+ return;
+ }
+ if (!parseSrflxCandidate(candidate)) {
+ this.send(candidate);
+ return;
+ }
+ this.pendingSrflx.push(candidate);
+ this.trySendPredictions();
+ }
+
+ complete(): void {
+ if (this.completed) {
+ return;
+ }
+ this.trySendPredictions();
+ for (const candidate of this.pendingSrflx.splice(0)) {
+ this.send(candidate);
+ }
+ this.send(null);
+ this.completed = true;
+ }
+
+ discard(): void {
+ this.completed = true;
+ this.pendingSrflx.length = 0;
+ }
+
+ private trySendPredictions(): void {
+ if (this.predictionsSent) {
+ return;
+ }
+ const predictions = predictSrflxCandidates(this.pendingSrflx);
+ if (predictions.length === 0) {
+ return;
+ }
+ for (const candidate of predictions) {
+ this.send(candidate);
+ }
+ this.predictionsSent = true;
+ for (const candidate of this.pendingSrflx.splice(0)) {
+ this.send(candidate);
+ }
+ }
+}
diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts
index 67dabd0..ecb05c5 100644
--- a/tests/host-peer-tracks.test.ts
+++ b/tests/host-peer-tracks.test.ts
@@ -17,6 +17,7 @@ import { ViewerRelay } from "../src/client/webrtc/viewer-relay.ts";
import type {
IceConfig,
ParticipantRouteAssignment,
+ SignalPayload,
} from "../src/shared/protocol.ts";
const codecPreflight = vi.hoisted(() => ({
@@ -109,12 +110,16 @@ class FakePeerConnection {
private releaseRemoteDescription: (() => void) | null = null;
connectionState: RTCPeerConnectionState = "new";
iceConnectionState: RTCIceConnectionState = "new";
+ iceGatheringState: RTCIceGatheringState = "new";
signalingState: RTCSignalingState = "stable";
localDescription: RTCSessionDescription | null = null;
remoteDescription: RTCSessionDescription | null = null;
readonly addedIceCandidates: Array = [];
readonly statsReports: Array> = [];
- private readonly eventListeners = new Map void>>();
+ private readonly eventListeners = new Map<
+ string,
+ Array<(event: Event) => void>
+ >();
constructor(configuration?: RTCConfiguration) {
FakePeerConnection.latest = this;
@@ -160,7 +165,7 @@ class FakePeerConnection {
} as unknown as RTCRtpTransceiver;
}
- addEventListener(type: string, listener: () => void): void {
+ addEventListener(type: string, listener: (event: Event) => void): void {
const listeners = this.eventListeners.get(type) ?? [];
listeners.push(listener);
this.eventListeners.set(type, listeners);
@@ -168,7 +173,7 @@ class FakePeerConnection {
dispatchEvent(event: Event): boolean {
for (const listener of this.eventListeners.get(event.type) ?? []) {
- listener();
+ listener(event);
}
return true;
}
@@ -417,6 +422,7 @@ function createPeer(
iceConfig: IceConfig = { iceServers: [] },
profile: QualityProfile = QUALITY_PROFILES["720p30"],
videoCodec: BrowserVideoCodecPreference = VP8_ONLY_VIDEO_CODEC,
+ natPredictionEnabled = false,
): HostPeer {
return new HostPeer(
"viewer-peer",
@@ -428,6 +434,8 @@ function createPeer(
onUpdate,
},
videoCodec,
+ undefined,
+ natPredictionEnabled,
);
}
@@ -712,6 +720,77 @@ describe("HostPeer source replacement", () => {
}
});
+ it("keeps ordinary candidates while opting into bounded NAT predictions", () => {
+ const signals: SignalPayload[] = [];
+ const peer = new HostPeer(
+ "viewer-peer",
+ { iceServers: [{ urls: "stun:share.example.test:3478" }] },
+ createStream(createTrack("video", "video"), null),
+ QUALITY_PROFILES["720p30"],
+ {
+ sendSignal: (_peerId, payload) => {
+ signals.push(payload);
+ return true;
+ },
+ onUpdate: () => undefined,
+ },
+ VP8_ONLY_VIDEO_CODEC,
+ undefined,
+ true,
+ );
+ const connection = FakePeerConnection.latest!;
+ expect(connection.configurations[0]?.iceServers).toEqual([
+ { urls: "stun:share.example.test:3478" },
+ { urls: "stun:share.example.test:3479" },
+ { urls: "stun:share.example.test:3480" },
+ ]);
+
+ const emitCandidate = (port: number | null): void => {
+ const event = new Event("icecandidate");
+ Object.defineProperty(event, "candidate", {
+ value:
+ port === null
+ ? null
+ : {
+ candidate:
+ `candidate:base 1 udp 2122260223 203.0.113.7 ${port} ` +
+ "typ srflx raddr 192.0.2.7 rport 50000 generation 0 ufrag test",
+ sdpMid: "0",
+ sdpMLineIndex: 0,
+ usernameFragment: "test",
+ },
+ });
+ connection.dispatchEvent(event);
+ };
+ emitCandidate(40_000);
+ emitCandidate(40_003);
+ emitCandidate(40_006);
+ connection.iceGatheringState = "complete";
+ connection.dispatchEvent(new Event("icegatheringstatechange"));
+ emitCandidate(null);
+
+ const candidateSignals = signals.filter(
+ (signal): signal is Extract =>
+ signal.kind === "candidate",
+ );
+ const predictionIndex = candidateSignals.findIndex((signal) =>
+ signal.candidate?.candidate.includes("candidate:s"),
+ );
+ const ordinaryIndex = candidateSignals.findIndex((signal) =>
+ signal.candidate?.candidate.startsWith("candidate:base"),
+ );
+ expect(predictionIndex).toBeGreaterThanOrEqual(0);
+ expect(predictionIndex).toBeLessThan(ordinaryIndex);
+ expect(
+ candidateSignals.filter((signal) =>
+ signal.candidate?.candidate.startsWith("candidate:base"),
+ ),
+ ).toHaveLength(3);
+ expect(candidateSignals.at(-1)?.candidate).toBeNull();
+ expect(candidateSignals.filter((signal) => signal.candidate === null)).toHaveLength(1);
+ peer.dispose();
+ });
+
it("reserves send-only video and audio senders and replaces both tracks", async () => {
const oldVideo = createTrack("video", "old-video");
const oldAudio = createTrack("audio", "old-audio");
diff --git a/tests/nat-prediction.test.ts b/tests/nat-prediction.test.ts
new file mode 100644
index 0000000..514475b
--- /dev/null
+++ b/tests/nat-prediction.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ iceServersWithNatPrediction,
+ MAX_NAT_PREDICTION_CANDIDATES,
+ NatPredictionCandidateBatch,
+ predictSrflxCandidates,
+ type SignalCandidate,
+} from "../src/client/webrtc/nat-prediction.ts";
+
+function candidate(port: number, address = "203.0.113.7"): SignalCandidate {
+ return {
+ candidate:
+ `candidate:base 1 udp 2122260223 ${address} ${port} ` +
+ "typ srflx raddr 192.0.2.7 rport 50000 generation 0 ufrag test",
+ sdpMid: "0",
+ sdpMLineIndex: 0,
+ usernameFragment: "test",
+ };
+}
+
+function hostCandidate(): SignalCandidate {
+ return {
+ candidate:
+ "candidate:host 1 udp 2122260223 192.0.2.7 50000 typ host generation 0 ufrag test",
+ sdpMid: "0",
+ sdpMLineIndex: 0,
+ usernameFragment: "test",
+ };
+}
+
+function portOf(value: SignalCandidate): number {
+ return Number(value?.candidate.trim().split(/\s+/)[5]);
+}
+
+describe("NAT prediction ICE adapter", () => {
+ it("leaves the configured servers unchanged when disabled", () => {
+ const servers = [
+ { urls: ["stun:share.example.test:3478"] },
+ { urls: "stun:other.example.test:5349" },
+ ];
+
+ expect(iceServersWithNatPrediction(servers, false)).toEqual(servers);
+ });
+
+ it("derives only the two optional same-host survey listeners", () => {
+ const servers = [
+ { urls: ["stun:share.example.test:3478", "stun:share.example.test:3478"] },
+ { urls: "stun:other.example.test:3478" },
+ ];
+
+ expect(iceServersWithNatPrediction(servers, true)).toEqual([
+ ...servers,
+ { urls: "stun:share.example.test:3479" },
+ { urls: "stun:share.example.test:3480" },
+ ]);
+ });
+
+ it("does not duplicate already configured auxiliary listeners", () => {
+ const servers = [
+ { urls: "stun:share.example.test:3478" },
+ { urls: "stun:share.example.test:3479" },
+ ];
+
+ expect(iceServersWithNatPrediction(servers, true)).toEqual([
+ ...servers,
+ { urls: "stun:share.example.test:3480" },
+ ]);
+ });
+
+ it("predicts a bounded two-sided window for a stable arithmetic shape", () => {
+ const predictions = predictSrflxCandidates([
+ candidate(40_000),
+ candidate(40_003),
+ candidate(40_006),
+ ]);
+
+ expect(predictions.length).toBeLessThanOrEqual(
+ MAX_NAT_PREDICTION_CANDIDATES,
+ );
+ expect(predictions.map(portOf)).toEqual(
+ expect.arrayContaining([40_009, 39_997]),
+ );
+ expect(predictions.every((value) => value?.sdpMid === "0")).toBe(true);
+ });
+
+ it("does not predict an unstable or incomplete mapping shape", () => {
+ expect(predictSrflxCandidates([candidate(40_000), candidate(40_001)])).toEqual(
+ [],
+ );
+ expect(
+ predictSrflxCandidates([
+ candidate(40_000),
+ candidate(40_003),
+ candidate(40_011),
+ ]),
+ ).toEqual([]);
+ });
+
+ it("sends predictions before held srflx candidates while retaining every one", () => {
+ const sent: Array = [];
+ const batch = new NatPredictionCandidateBatch((value) => sent.push(value));
+ const first = candidate(40_000);
+ const second = candidate(40_003);
+ const third = candidate(40_006);
+
+ batch.add(hostCandidate());
+ batch.add(first);
+ batch.add(second);
+ batch.add(third);
+ batch.complete();
+ batch.complete();
+
+ expect(sent[0]).toEqual(hostCandidate());
+ const firstOriginal = sent.indexOf(first);
+ const firstPrediction = sent.findIndex(
+ (value) => value?.candidate.includes("candidate:s") ?? false,
+ );
+ expect(firstPrediction).toBeGreaterThanOrEqual(1);
+ expect(firstPrediction).toBeLessThan(firstOriginal);
+ expect(sent.filter((value) => value === first)).toHaveLength(1);
+ expect(sent.filter((value) => value === second)).toHaveLength(1);
+ expect(sent.filter((value) => value === third)).toHaveLength(1);
+ expect(sent.at(-1)).toBeNull();
+ });
+
+ it("flushes ordinary candidates without inventing a prediction", () => {
+ const sent: Array = [];
+ const batch = new NatPredictionCandidateBatch((value) => sent.push(value));
+ const first = candidate(40_000);
+ const second = candidate(40_007);
+
+ batch.add(first);
+ batch.add(second);
+ batch.complete();
+
+ expect(sent).toEqual([first, second, null]);
+ });
+});