@torrent-tv/proxy 2.80.10 → 2.80.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -79,7 +79,13 @@ test("the read goes on after the budget, so the next session of the file gets th
79
79
  assert.equal(first.arrived, false);
80
80
 
81
81
  answerLate({ times: [0, 5, 10], tolerance: 0, format: "matroska" });
82
- await new Promise((resolve) => setTimeout(resolve, 10));
82
+ // Every microtask this resolution queues, and not a chosen ten milliseconds.
83
+ // `setImmediate` runs after the whole microtask queue of this turn, so the
84
+ // answer has been taken in by the time it fires — where the ten milliseconds
85
+ // were a guess about how long that takes on whatever machine is running.
86
+ // Asking the reader again instead would have counted as another read, which
87
+ // is the very thing the last assertion here is about.
88
+ await new Promise((resolve) => { setImmediate(resolve); });
83
89
 
84
90
  const second = await sessions.readKeyframeTableWithin(FILE);
85
91
  assert.deepEqual(second.times, [0, 5, 10], "the late answer was kept, not thrown away");
@@ -1,105 +1,220 @@
1
- /**
2
- * @file One place decides which encoders exist.
3
- *
4
- * Not a check of behaviour but of shape, and it is here because the shape is
5
- * what failed. Three separate places used to decide where an encoder should
6
- * work and whether it should go on living, and they disagreed on every pass:
7
- * measured in the field on 2026-09-05, 684 starts and 660 stops in 482 seconds,
8
- * of which 294 were one place killing what another had just decided to keep,
9
- * while the viewer's own segment went unmade for 32.3 seconds.
10
- *
11
- * Every rule below is one that was broken then. A reader who needs to add a
12
- * fourth place should read this file first and then not.
13
- */
14
-
15
- import test from "node:test";
16
- import assert from "node:assert/strict";
17
- import { readFileSync } from "node:fs";
18
- import path from "node:path";
19
- import { fileURLToPath } from "node:url";
20
-
21
- const HERE = path.dirname(fileURLToPath(import.meta.url));
22
-
23
- /**
24
- * @param {string} relative
25
- * @returns {string}
26
- */
27
- function source(relative) {
28
- return readFileSync(path.join(HERE, "..", relative), "utf8");
29
- }
30
-
31
- /**
32
- * Lines of code, without comments or blanks: a rule about what the code does
33
- * must not be answered by what a comment says about it.
34
- *
35
- * @param {string} text
36
- * @returns {string[]}
37
- */
38
- function statements(text) {
39
- return text
40
- .split("\n")
41
- .map((line) => line.trim())
42
- .filter((line) => line.length > 0 && !line.startsWith("//") && !line.startsWith("*") && !line.startsWith("/*"));
43
- }
44
-
45
- test("an encoder is stopped for scheduling reasons in exactly one place", () => {
46
- // The orchestrator decides; nobody else may. What is left in the session
47
- // manager is teardown — the session is going away and its encoders with it —
48
- // which is not a decision about which encoders should exist.
49
- const orchestrator = statements(source("services/orchestrators/EncodeOrchestrator.js"));
50
- const stopsInOrchestrator = orchestrator.filter((line) => line.includes("run.stop("));
51
- assert.equal(stopsInOrchestrator.length, 1, "the orchestrator stops runs in one place");
52
-
53
- const manager = statements(source("services/hls-session-manager.js"));
54
- const stopsInManager = manager.filter((line) => line.includes(".stop("));
55
- assert.equal(
56
- stopsInManager.length,
57
- 2,
58
- "the session manager stops runs only when a session is torn down: " +
59
- stopsInManager.join(" / ")
60
- );
61
- });
62
-
63
- test("nothing outside the encoding layer starts an encoder", () => {
64
- // A run is built in one place. Two places building them is how a start came
65
- // to kill what the plan had decided to keep — the killing lived in the
66
- // building.
67
- const manager = statements(source("services/hls-session-manager.js"));
68
- const builds = manager.filter((line) => line.includes("new EncodeRun("));
69
- assert.equal(builds.length, 1, "one place builds an encoder");
70
- });
71
-
72
- test("starting an encoder stops nothing", () => {
73
- // The rule that broke it: the start path looked for a live run whose own
74
- // start was not below the new one's and killed it. It is not enough that the
75
- // line is gone — the words it was written with must not come back.
76
- const manager = source("services/hls-session-manager.js");
77
- assert.equal(
78
- manager.includes("previousRun"),
79
- false,
80
- "there is no such thing as the previous run: a session holds several"
81
- );
82
- assert.equal(manager.includes("a new run is taking its place"), false);
83
- });
84
-
85
- test("a seek moves the viewer and nothing else", () => {
86
- // It used to do eleven things and write the position into five places. What
87
- // follows from a viewer moving is the map's business, and the orchestrators
88
- // read the map.
89
- const manager = source("services/hls-session-manager.js");
90
- const seek = manager.slice(
91
- manager.indexOf("requestSeek(sessionId, positionSeconds"),
92
- manager.indexOf("requestSeek(sessionId, positionSeconds") + 2000
93
- );
94
- const body = seek.slice(0, seek.indexOf("\n }\n"));
95
- assert.equal(body.includes("#startEncodeRun"), false, "a seek starts no encoder");
96
- assert.equal(body.includes("setTimeout"), false, "and waits for nothing");
97
- });
98
-
99
- test("how far an encoder may work is answered once", () => {
100
- // The plan computes the stretch and it reaches ffmpeg. A second computation
101
- // somewhere else is what made the first one pointless: it was passed and then
102
- // dropped by a parameter list that did not name it.
103
- const orchestrator = source("services/orchestrators/EncodeOrchestrator.js");
104
- assert.ok(orchestrator.includes("makeRun({ address, from, to })"), "the stretch is handed over");
105
- });
1
+ /**
2
+ * @file One place decides which encoders exist.
3
+ *
4
+ * Not a check of behaviour but of shape, and it is here because the shape is
5
+ * what failed. Three separate places used to decide where an encoder should
6
+ * work and whether it should go on living, and they disagreed on every pass:
7
+ * measured in the field on 2026-09-05, 684 starts and 660 stops in 482 seconds,
8
+ * of which 294 were one place killing what another had just decided to keep,
9
+ * while the viewer's own segment went unmade for 32.3 seconds.
10
+ *
11
+ * Every rule below is one that was broken then. A reader who needs to add a
12
+ * fourth place should read this file first and then not.
13
+ */
14
+
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { readFileSync } from "node:fs";
18
+ import path from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
22
+
23
+ /**
24
+ * @param {string} relative
25
+ * @returns {string}
26
+ */
27
+ function source(relative) {
28
+ return readFileSync(path.join(HERE, "..", relative), "utf8");
29
+ }
30
+
31
+ /**
32
+ * Lines of code, without comments or blanks: a rule about what the code does
33
+ * must not be answered by what a comment says about it.
34
+ *
35
+ * @param {string} text
36
+ * @returns {string[]}
37
+ */
38
+ function statements(text) {
39
+ return text
40
+ .split("\n")
41
+ .map((line) => line.trim())
42
+ .filter((line) => line.length > 0 && !line.startsWith("//") && !line.startsWith("*") && !line.startsWith("/*"));
43
+ }
44
+
45
+ test("an encoder is stopped for scheduling reasons in exactly one place", () => {
46
+ // The orchestrator decides; nobody else may. What is left in the session
47
+ // manager is teardown — the session is going away and its encoders with it —
48
+ // which is not a decision about which encoders should exist.
49
+ const orchestrator = statements(source("services/orchestrators/EncodeOrchestrator.js"));
50
+ const stopsInOrchestrator = orchestrator.filter((line) => line.includes("run.stop("));
51
+ assert.equal(stopsInOrchestrator.length, 1, "the orchestrator stops runs in one place");
52
+
53
+ const manager = statements(source("services/hls-session-manager.js"));
54
+ const stopsInManager = manager.filter((line) => line.includes(".stop("));
55
+ assert.equal(
56
+ stopsInManager.length,
57
+ 2,
58
+ "the session manager stops runs only when a session is torn down: " +
59
+ stopsInManager.join(" / ")
60
+ );
61
+ });
62
+
63
+ test("nothing outside the encoding layer starts an encoder", () => {
64
+ // A run is built in one place. Two places building them is how a start came
65
+ // to kill what the plan had decided to keep — the killing lived in the
66
+ // building.
67
+ const manager = statements(source("services/hls-session-manager.js"));
68
+ const builds = manager.filter((line) => line.includes("new EncodeRun("));
69
+ assert.equal(builds.length, 1, "one place builds an encoder");
70
+ });
71
+
72
+ test("starting an encoder stops nothing", () => {
73
+ // The rule that broke it: the start path looked for a live run whose own
74
+ // start was not below the new one's and killed it. It is not enough that the
75
+ // line is gone — the words it was written with must not come back.
76
+ const manager = source("services/hls-session-manager.js");
77
+ assert.equal(
78
+ manager.includes("previousRun"),
79
+ false,
80
+ "there is no such thing as the previous run: a session holds several"
81
+ );
82
+ assert.equal(manager.includes("a new run is taking its place"), false);
83
+ });
84
+
85
+ test("a seek moves the viewer and nothing else", () => {
86
+ // It used to do eleven things and write the position into five places. What
87
+ // follows from a viewer moving is the map's business, and the orchestrators
88
+ // read the map.
89
+ const manager = source("services/hls-session-manager.js");
90
+ const seek = manager.slice(
91
+ manager.indexOf("requestSeek(sessionId, positionSeconds"),
92
+ manager.indexOf("requestSeek(sessionId, positionSeconds") + 2000
93
+ );
94
+ const body = seek.slice(0, seek.indexOf("\n }\n"));
95
+ assert.equal(body.includes("#startEncodeRun"), false, "a seek starts no encoder");
96
+ assert.equal(body.includes("setTimeout"), false, "and waits for nothing");
97
+ });
98
+
99
+ test("how far an encoder may work is answered once", () => {
100
+ // The plan computes the stretch and it reaches ffmpeg. A second computation
101
+ // somewhere else is what made the first one pointless: it was passed and then
102
+ // dropped by a parameter list that did not name it.
103
+ const orchestrator = source("services/orchestrators/EncodeOrchestrator.js");
104
+ assert.match(
105
+ orchestrator,
106
+ /makeRun\(\{ address, from, to, because \}\)/,
107
+ "the stretch is handed over, and so are the plan's own words for why"
108
+ );
109
+ });
110
+
111
+ test("only the plan places an encoder", () => {
112
+ // The count is the whole of this item. There were eight other places: the
113
+ // first run of a session, a viewer joining it further in, a rung or a
114
+ // soundtrack being warmed, one being switched to, a hardware encoder falling
115
+ // back to software, an input coming back, a cut table correcting itself, and
116
+ // a settled seek. Each of them chose a position by a rule of its own, and the
117
+ // plan — which is arithmetic over what is made, what is being made and what is
118
+ // wanted — was left to compare its answer against theirs.
119
+ const manager = source("services/hls-session-manager.js");
120
+ const starts = [...manager.matchAll(/this\.#startEncodeRun\(/g)].length;
121
+ assert.equal(starts, 1, "one caller, and it is the one the plan asks through");
122
+ assert.match(
123
+ manager,
124
+ /#makeRunAt\(address, from, to, because\)[\s\S]{0,3000}this\.#startEncodeRun\(base, from, because, \{ to \}\)/,
125
+ "and that caller is what the plan is given to build runs with"
126
+ );
127
+ });
128
+
129
+ test("nobody stops an encoder for being unwatched", () => {
130
+ // Whether an encoder is still wanted is the same question as where one should
131
+ // be, and the plan answers it: an output with nobody on it has a priority map
132
+ // with nothing in it. Answered here as well, it was answered twice by two
133
+ // rules — and since a viewer moving between steps announces itself, the plan
134
+ // started again what this class had just killed, several times a second.
135
+ const manager = source("services/hls-session-manager.js");
136
+ assert.equal(
137
+ manager.includes("no viewer is watching"),
138
+ false,
139
+ "a rung nobody is on is a fact, not an act"
140
+ );
141
+ assert.equal(manager.includes("no viewer is listening to audio track"), false);
142
+ assert.equal(
143
+ manager.includes("warmed for a switch the viewer did not make"),
144
+ false,
145
+ "an abandoned warm-up is a viewer leaving an output"
146
+ );
147
+ assert.equal(manager.includes("prepared for a track change the viewer did not make"), false);
148
+ });
149
+
150
+ test("the seek settle machinery is gone, whole", () => {
151
+ // A viewer's position is applied the moment they state it. The settle was a
152
+ // second debounce on a signal the browser had already debounced, and every
153
+ // millisecond of it was dead time in front of the viewer; the cooldown behind
154
+ // it existed because segment REQUESTS once steered the encoder.
155
+ const manager = source("services/hls-session-manager.js");
156
+ for (const gone of [
157
+ "seekSettleTimer",
158
+ "seekTarget",
159
+ "seekFirstFarAt",
160
+ "SEEK_SETTLE_MS",
161
+ "SEEK_SETTLE_MAX_MS",
162
+ "RESTART_COOLDOWN_MS",
163
+ "SEEK_BACKOFF_SEGMENTS",
164
+ "#fireSettledSeek",
165
+ "#seekSession"
166
+ ]) {
167
+ assert.equal(manager.includes(gone), false, `${gone} is gone`);
168
+ }
169
+ });
170
+
171
+ test("where a soundtrack begins is read off the table, not handed in", () => {
172
+ // The instant a number really begins is a fact of the FILE's cutting, held in
173
+ // the live table every session of the file shares. Passed as an argument by
174
+ // the one caller that had measured it, only a run started by that caller ever
175
+ // had it, and a run the plan placed at the same number landed apart again.
176
+ const manager = source("services/hls-session-manager.js");
177
+ assert.match(
178
+ manager,
179
+ /const positionSecondsOverride = session\.audioOnly === true\s*\n?\s*\? trueStartOf\(session\.timeline, startIndex\)/,
180
+ "derived where the run is built"
181
+ );
182
+ assert.equal(
183
+ manager.includes("this.#startEncodeRun(member, index, trueStart)"),
184
+ false,
185
+ "and not carried in from the correction that measured it"
186
+ );
187
+ });
188
+
189
+ test("a changed bitrate cap stops the encoder carrying the old one and nothing more", () => {
190
+ // An argument list is fixed when a process starts, so a run carrying the
191
+ // previous cap cannot be told about the new one — that is what is known here.
192
+ // Where the replacement stands is a different question, and the old answer to
193
+ // it was neither where a viewer is nor a gap in the material: it was the
194
+ // segment the process being replaced happened to have reached.
195
+ const manager = source("services/hls-session-manager.js");
196
+ assert.equal(manager.includes("#restartAtViewer"), false, "the second answer is gone");
197
+ assert.match(
198
+ manager,
199
+ /#reencodeAtNewRate\(session\) \{\s*\n\s*this\.#stopEncodeRun\(session, "its bitrate cap changed"\);\s*\n\s*this\.planEncodersSoon\(\);\s*\n\s*\}/,
200
+ "stopped, and then decided again"
201
+ );
202
+ });
203
+
204
+ test("each output is handed its own priority map, and the plan is what reads it", () => {
205
+ // The map is one fact asked at two scopes, and both are right for what asks
206
+ // them: the swarm is asked for bytes of a FILE, which every output of it
207
+ // reads, and encoders are placed per OUTPUT, which a person watching 480p
208
+ // wants nothing of at 1080p.
209
+ const manager = source("services/hls-session-manager.js");
210
+ assert.match(
211
+ manager,
212
+ /notePriorityMap\([\s\S]{0,200}mapForOutput\(address\)/,
213
+ "the encoding reads the output's own map"
214
+ );
215
+ assert.equal(
216
+ manager.includes("this.priority.mapFor("),
217
+ false,
218
+ "and never the whole film's, which wanted an encoder on every output of it"
219
+ );
220
+ });
@@ -23,6 +23,25 @@ import { SharedPieceStore } from "../services/piece-store/shared-piece-store.js"
23
23
 
24
24
  const PIECE = 1024;
25
25
 
26
+ /**
27
+ * Wait for the thing being asserted, not for a length of time.
28
+ *
29
+ * @param {() => boolean} until
30
+ * @param {string} what - Named in the failure, since a timeout otherwise says
31
+ * only that something did not happen.
32
+ * @param {number} [limit]
33
+ * @returns {Promise<void>}
34
+ */
35
+ async function waitFor(until, what, limit = 10_000) {
36
+ const deadline = Date.now() + limit;
37
+ while (!until()) {
38
+ if (Date.now() > deadline) {
39
+ throw new Error(`${what} never happened`);
40
+ }
41
+ await new Promise((resolve) => { setTimeout(resolve, 5); });
42
+ }
43
+ }
44
+
26
45
  /**
27
46
  * A disk that answers only when the test lets it, so writes can be made slower
28
47
  * than arrivals on purpose — which is the whole of the field condition.
@@ -85,8 +104,19 @@ test("memory in use never runs past the allowance while the disk is behind", asy
85
104
  for (let index = capacity; index < capacity + 40; index += 1) {
86
105
  arrivals.push(put(store, index).catch(() => undefined));
87
106
  }
88
- // Let them get as far as they can with the disk answering nothing.
89
- await new Promise((resolve) => setTimeout(resolve, 200));
107
+ // Until the state being asserted has been reached: a write is outstanding
108
+ // and admission has waited for the disk rather than evicting. Waited out as
109
+ // 200 ms instead, this asserted how far the arrivals had got in that time —
110
+ // and on a machine slow enough that none of them reached a disk write, both
111
+ // of those figures are zero and the test fails with nothing wrong.
112
+ //
113
+ // The bound below is what the wait must NOT decide, so it is checked after
114
+ // the state is reached rather than after an interval: the store may never
115
+ // hold more blocks than it is allowed, however many arrivals are in flight.
116
+ await waitFor(() => {
117
+ const now = store.stats();
118
+ return now.blocksInFlight > 0 && now.waitedForDisk > 0;
119
+ }, "a held write and an admission that waited for it");
90
120
 
91
121
  const held = store.stats();
92
122
  // The check the field failure would fail: a store allowed four pieces held
@@ -0,0 +1,211 @@
1
+ /**
2
+ * @file One fact asked at two scopes: what the swarm is asked for, and what the
3
+ * encoders are placed by.
4
+ *
5
+ * Both are right for what asks them. The swarm is asked for bytes of a FILE,
6
+ * and the picture, a quality step and a soundtrack of one film read the same
7
+ * bytes — so every viewer of any of them wants that file's bytes. Encoders are
8
+ * placed per OUTPUT, and a person watching 480p wants nothing of the 1080p
9
+ * output at all.
10
+ *
11
+ * One map for both was the second authority over encoders. Every output of a
12
+ * film was handed the whole film's map, so the plan wanted an encoder on every
13
+ * one of them; what actually stopped the ones nobody was watching was the
14
+ * session manager killing them by its own judgement — and since a viewer moving
15
+ * between steps also announces itself, the plan started them again on the next
16
+ * pass. Field 2026-09-08.
17
+ *
18
+ * Built out of the real classes throughout: the real viewer registry holds real
19
+ * viewers, the real `LiveOutputs` answers what a film's shape is, and the real
20
+ * `PriorityOrchestrator` builds the maps. A session is a plain object in this
21
+ * proxy — there is no class for one — so the literals below are the thing
22
+ * itself and not a stand-in for it.
23
+ */
24
+
25
+ import test from "node:test";
26
+ import assert from "node:assert/strict";
27
+ import { PriorityOrchestrator } from "../services/priority/PriorityOrchestrator.js";
28
+ import { LiveOutputs } from "../services/output/LiveOutputs.js";
29
+ import { Viewers } from "../services/viewer/Viewers.js";
30
+ import { viewersOf } from "../services/viewer/Viewer.js";
31
+ import { runsOf } from "../services/priority/PriorityMap.js";
32
+
33
+ const FILM = { sourceKey: "source-1", fileIndex: 0, durationSeconds: 600 };
34
+ const STALE_AFTER_MS = 60_000;
35
+
36
+ /**
37
+ * A session, as much of one as these classes read.
38
+ *
39
+ * @param {{ id: string, outputKey: string, isStep?: boolean, audioOnly?: boolean }} params
40
+ * @returns {object}
41
+ */
42
+ function outputOf({ id, outputKey, isStep = false, audioOnly = false }) {
43
+ return {
44
+ id,
45
+ outputKey,
46
+ isStep,
47
+ audioOnly,
48
+ state: "ready",
49
+ sourceKey: FILM.sourceKey,
50
+ fileIndex: FILM.fileIndex,
51
+ file: { key: "film-1", durationSeconds: FILM.durationSeconds }
52
+ };
53
+ }
54
+
55
+ /**
56
+ * The real orchestrator over the real registry, wired the way the session
57
+ * manager wires it.
58
+ *
59
+ * @param {object[]} sessions
60
+ * @returns {{ priority: PriorityOrchestrator, viewers: Viewers, publish: () => void }}
61
+ */
62
+ function over(sessions) {
63
+ const live = new LiveOutputs({ sessionsById: new Map(sessions.map((one) => [one.id, one])) });
64
+ const viewers = new Viewers();
65
+ const priority = new PriorityOrchestrator({
66
+ publish: () => {},
67
+ viewersOf: (session) => viewersOf(session),
68
+ allowanceFor: () => 10,
69
+ watchedBy: (session, viewer) => live.watchedBy(session, viewer)
70
+ });
71
+ return {
72
+ priority,
73
+ viewers,
74
+ live,
75
+ publish: () => priority.publishFor({ sessionGroups: [sessions], staleAfterMs: STALE_AFTER_MS })
76
+ };
77
+ }
78
+
79
+ test("a person on a step wants nothing of the picture they stepped off", () => {
80
+ const picture = outputOf({ id: "pic", outputKey: "out:1080" });
81
+ const step = outputOf({ id: "step", outputKey: "out:480", isStep: true });
82
+ const { priority, viewers, publish } = over([picture, step]);
83
+ // ONE viewer object per person, referenced from both outputs — which is why
84
+ // the step they are on is known when the picture is asked about.
85
+ viewers.of(picture, "p");
86
+ const person = viewers.of(step, "p");
87
+ person.moveTo(300);
88
+ person.activeVariantId = "step";
89
+ publish();
90
+
91
+ assert.ok(
92
+ runsOf(priority.mapForOutput("out:480")).length > 0,
93
+ "the step they are watching is wanted"
94
+ );
95
+ assert.deepEqual(
96
+ runsOf(priority.mapForOutput("out:1080")),
97
+ [],
98
+ "and the picture is producing for nobody, which is what makes its encoder unwanted"
99
+ );
100
+ // The bytes are another matter: both outputs read the same file, and the
101
+ // swarm is asked for the file.
102
+ assert.ok(
103
+ runsOf(priority.mapFor(FILM.sourceKey, FILM.fileIndex)).length > 0,
104
+ "the download still wants the film, because that is what is being watched"
105
+ );
106
+ });
107
+
108
+ test("a soundtrack is wanted by whoever is registered on it", () => {
109
+ const picture = outputOf({ id: "pic", outputKey: "out:1080" });
110
+ const sound = outputOf({ id: "rus", outputKey: "out:a1", audioOnly: true });
111
+ const { priority, viewers, publish } = over([picture, sound]);
112
+ // Standing on a STEP of the picture says nothing about the sound: the tracks
113
+ // nobody chose are let go of where a track is chosen, so being known to a
114
+ // soundtrack is listening to it.
115
+ const person = viewers.of(sound, "p");
116
+ person.moveTo(120);
117
+ person.activeVariantId = "step";
118
+ publish();
119
+
120
+ assert.ok(runsOf(priority.mapForOutput("out:a1")).length > 0);
121
+ });
122
+
123
+ test("a step being warmed is wanted, and so is the picture still on screen", () => {
124
+ // Both are genuinely being produced through a warm-up, which is the price of
125
+ // the switch not being visible. Whether the machine can afford two encoders
126
+ // is the budget's question and not this one's.
127
+ const picture = outputOf({ id: "pic", outputKey: "out:1080" });
128
+ const step = outputOf({ id: "step", outputKey: "out:480", isStep: true });
129
+ const { priority, viewers, publish } = over([picture, step]);
130
+ viewers.of(picture, "p");
131
+ const person = viewers.of(step, "p");
132
+ person.moveTo(300);
133
+ person.warmingVariantId = "step";
134
+ publish();
135
+
136
+ assert.ok(runsOf(priority.mapForOutput("out:1080")).length > 0, "still on screen");
137
+ assert.ok(runsOf(priority.mapForOutput("out:480")).length > 0, "being made ready");
138
+ });
139
+
140
+ test("an output everybody has left is stated as empty, not left as it was", () => {
141
+ // The difference matters: the plan stops the encoders of an output whose map
142
+ // is empty, and would keep the map it had when somebody was watching.
143
+ const picture = outputOf({ id: "pic", outputKey: "out:1080" });
144
+ const { priority, viewers, publish } = over([picture]);
145
+ viewers.of(picture, "p").moveTo(300);
146
+
147
+ publish();
148
+ assert.ok(runsOf(priority.mapForOutput("out:1080")).length > 0);
149
+
150
+ viewers.leaves(picture, "p");
151
+ publish();
152
+ assert.deepEqual(runsOf(priority.mapForOutput("out:1080")), []);
153
+ });
154
+
155
+ test("a viewer nothing has been heard from is not watching anything", () => {
156
+ // The backstop for a viewer who never said they were leaving: a browser whose
157
+ // tab is gone releases nothing, and their own silence is what expires.
158
+ const picture = outputOf({ id: "pic", outputKey: "out:1080" });
159
+ const live = new LiveOutputs({ sessionsById: new Map([["pic", picture]]) });
160
+ const viewers = new Viewers();
161
+ const priority = new PriorityOrchestrator({
162
+ publish: () => {},
163
+ viewersOf: (session) => viewersOf(session),
164
+ allowanceFor: () => 10,
165
+ watchedBy: (session, viewer) => live.watchedBy(session, viewer)
166
+ });
167
+ const person = viewers.of(picture, "p");
168
+ person.moveTo(300);
169
+
170
+ priority.publishFor({
171
+ sessionGroups: [[picture]],
172
+ staleAfterMs: STALE_AFTER_MS,
173
+ now: person.lastSeenAt + STALE_AFTER_MS + 1
174
+ });
175
+
176
+ assert.deepEqual(runsOf(priority.mapForOutput("out:1080")), []);
177
+ });
178
+
179
+ test("a file and an output that are gone are forgotten", () => {
180
+ // These are the projection of the live sessions, never a memory of them. Left
181
+ // to accumulate they were three maps that only grew, and `forget` was written
182
+ // for that and called from nowhere.
183
+ const picture = outputOf({ id: "pic", outputKey: "out:1080" });
184
+ const { priority, viewers, publish } = over([picture]);
185
+ viewers.of(picture, "p").moveTo(300);
186
+
187
+ publish();
188
+ assert.ok(runsOf(priority.mapForOutput("out:1080")).length > 0);
189
+
190
+ priority.publishFor({ sessionGroups: [[]], staleAfterMs: STALE_AFTER_MS });
191
+ assert.deepEqual(runsOf(priority.mapForOutput("out:1080")), [], "the output is gone");
192
+ assert.deepEqual(
193
+ runsOf(priority.mapFor(FILM.sourceKey, FILM.fileIndex)),
194
+ [],
195
+ "and so is the file"
196
+ );
197
+ });
198
+
199
+ test("the picture is watched by a person who never moved off it", () => {
200
+ const picture = outputOf({ id: "pic", outputKey: "out:1080" });
201
+ const { viewers, live } = over([picture]);
202
+ const person = viewers.of(picture, "p");
203
+
204
+ assert.equal(live.watchedBy(picture, person), true, "no step is active");
205
+ person.activeVariantId = "pic";
206
+ assert.equal(
207
+ live.watchedBy(picture, person),
208
+ true,
209
+ "and naming the picture itself as the step is the same statement"
210
+ );
211
+ });