github-issue-tower-defence-management 1.106.0 → 1.108.0

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +5 -0
  3. package/bin/adapter/entry-points/cli/index.js +18 -1
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/dashboardComposeService.js +179 -0
  6. package/bin/adapter/entry-points/console/dashboardComposeService.js.map +1 -0
  7. package/bin/adapter/entry-points/console/webServer.js +29 -7
  8. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/machineStatusWriter.js +2 -0
  10. package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -1
  11. package/bin/adapter/repositories/ProcHostMetricsRepository.js +25 -2
  12. package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -1
  13. package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js +167 -0
  14. package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js.map +1 -0
  15. package/package.json +1 -1
  16. package/src/adapter/entry-points/cli/index.ts +36 -2
  17. package/src/adapter/entry-points/console/dashboardComposeService.test.ts +450 -0
  18. package/src/adapter/entry-points/console/dashboardComposeService.ts +201 -0
  19. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -0
  20. package/src/adapter/entry-points/console/webServer.test.ts +258 -54
  21. package/src/adapter/entry-points/console/webServer.ts +43 -10
  22. package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +14 -2
  23. package/src/adapter/entry-points/handlers/machineStatusWriter.ts +3 -0
  24. package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +26 -0
  25. package/src/adapter/repositories/ProcHostMetricsRepository.ts +36 -0
  26. package/src/domain/usecases/dashboard/ComposeDashboardUseCase.test.ts +432 -0
  27. package/src/domain/usecases/dashboard/ComposeDashboardUseCase.ts +228 -0
  28. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  29. package/types/adapter/entry-points/console/dashboardComposeService.d.ts +9 -0
  30. package/types/adapter/entry-points/console/dashboardComposeService.d.ts.map +1 -0
  31. package/types/adapter/entry-points/console/webServer.d.ts +4 -0
  32. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  33. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +1 -0
  34. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -1
  35. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +10 -1
  36. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -1
  37. package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts +29 -0
  38. package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts.map +1 -0
@@ -13,8 +13,15 @@ export type LoadAverages = {
13
13
  };
14
14
 
15
15
  const DEFAULT_PROC_DIRECTORY = '/proc';
16
+ const DEFAULT_ROOT_PATH = '/';
16
17
  const CPU_SAMPLE_INTERVAL_MS = 400;
17
18
 
19
+ export type DiskBlocks = {
20
+ blocks: number;
21
+ bfree: number;
22
+ bavail: number;
23
+ };
24
+
18
25
  export const parseMemoryUsedPercent = (meminfoText: string): number => {
19
26
  const fields = new Map<string, number>();
20
27
  for (const line of meminfoText.split('\n')) {
@@ -76,6 +83,19 @@ export const cpuUsedPercentFromSamples = (
76
83
  return Math.round((busyDelta / totalDelta) * 100);
77
84
  };
78
85
 
86
+ export const parseDiskUsedPercent = (
87
+ blocks: number,
88
+ bfree: number,
89
+ bavail: number,
90
+ ): number => {
91
+ const total = blocks - bfree + bavail;
92
+ if (total <= 0) {
93
+ throw new Error('disk total must be positive');
94
+ }
95
+ const used = blocks - bfree;
96
+ return Math.round((used / total) * 100);
97
+ };
98
+
79
99
  export const parseLoadAverages = (loadavgText: string): LoadAverages => {
80
100
  const parts = loadavgText.trim().split(/\s+/);
81
101
  const oneMinute = Number(parts[0]);
@@ -111,6 +131,17 @@ export class ProcHostMetricsRepository {
111
131
  new Promise((resolve) => {
112
132
  setTimeout(resolve, milliseconds);
113
133
  }),
134
+ private readonly readDiskBlocks: (rootPath: string) => DiskBlocks = (
135
+ rootPath,
136
+ ) => {
137
+ const stats = fs.statfsSync(rootPath);
138
+ return {
139
+ blocks: Number(stats.blocks),
140
+ bfree: Number(stats.bfree),
141
+ bavail: Number(stats.bavail),
142
+ };
143
+ },
144
+ private readonly rootPath: string = DEFAULT_ROOT_PATH,
114
145
  ) {}
115
146
 
116
147
  readMemoryUsedPercent = (): number =>
@@ -133,4 +164,9 @@ export class ProcHostMetricsRepository {
133
164
  parseLoadAverages(
134
165
  fs.readFileSync(path.join(this.procDirectory, 'loadavg'), 'utf8'),
135
166
  );
167
+
168
+ readDiskUsedPercent = (): number => {
169
+ const { blocks, bfree, bavail } = this.readDiskBlocks(this.rootPath);
170
+ return parseDiskUsedPercent(blocks, bfree, bavail);
171
+ };
136
172
  }
@@ -0,0 +1,432 @@
1
+ import {
2
+ ComposeDashboardInput,
3
+ ComposeDashboardUseCase,
4
+ PROJECT_ROW_WIDTH_BUDGET,
5
+ formatMachineStatusLines,
6
+ formatProjectHeaderLine,
7
+ formatProjectRowLine,
8
+ formatResetCountdown,
9
+ formatTokenRowLine,
10
+ roundHalfToEven,
11
+ } from './ComposeDashboardUseCase';
12
+ import { DashboardRow } from './GenerateDashboardRowUseCase';
13
+ import { TokenStatus } from './GenerateTokenStatusUseCase';
14
+
15
+ const codePointLength = (value: string): number => [...value].length;
16
+
17
+ const projectRow = (overrides: Partial<DashboardRow>): DashboardRow => ({
18
+ unread: 0,
19
+ todo: 0,
20
+ qc: 0,
21
+ fail: 0,
22
+ pr: 0,
23
+ ws: 0,
24
+ dep: 0,
25
+ blocker: 0,
26
+ ...overrides,
27
+ });
28
+
29
+ const tokenStatus = (overrides: Partial<TokenStatus>): TokenStatus => ({
30
+ name: 'token',
31
+ fiveHourUtilizationPercent: 0,
32
+ fiveHourResetSeconds: 0,
33
+ sevenDayUtilizationPercent: 0,
34
+ sevenDayResetSeconds: 0,
35
+ color: 'G',
36
+ prep: 0,
37
+ hum: 0,
38
+ ...overrides,
39
+ });
40
+
41
+ describe('roundHalfToEven', () => {
42
+ it('rounds halves to the nearest even integer like Python round()', () => {
43
+ expect(roundHalfToEven(0.5)).toBe(0);
44
+ expect(roundHalfToEven(1.5)).toBe(2);
45
+ expect(roundHalfToEven(2.5)).toBe(2);
46
+ expect(roundHalfToEven(3.5)).toBe(4);
47
+ expect(roundHalfToEven(16.0)).toBe(16);
48
+ expect(roundHalfToEven(0.49)).toBe(0);
49
+ expect(roundHalfToEven(0.51)).toBe(1);
50
+ });
51
+ });
52
+
53
+ describe('formatResetCountdown', () => {
54
+ it('renders d/h/m with zero-padded hours and minutes', () => {
55
+ expect(formatResetCountdown(0)).toBe('0d00h00');
56
+ expect(formatResetCountdown(3600)).toBe('0d01h00');
57
+ expect(formatResetCountdown(7200)).toBe('0d02h00');
58
+ expect(formatResetCountdown(86400 * 5)).toBe('5d00h00');
59
+ expect(formatResetCountdown(86400 + 3600 + 60)).toBe('1d01h01');
60
+ });
61
+
62
+ it('renders zero for a negative remaining countdown', () => {
63
+ expect(formatResetCountdown(-10)).toBe('0d00h00');
64
+ });
65
+ });
66
+
67
+ describe('formatMachineStatusLines', () => {
68
+ it('renders the host metrics as two lines from a machine status', () => {
69
+ expect(
70
+ formatMachineStatusLines({
71
+ memPct: 55,
72
+ cpuPct: 62,
73
+ diskPct: 93,
74
+ load: [16, 23, 40],
75
+ cycleMinutes: 13,
76
+ }),
77
+ ).toEqual(['M55% C62% D93% cy13', 'LA 16 23 40']);
78
+ });
79
+
80
+ it('rounds loads with half-to-even and renders integers', () => {
81
+ expect(
82
+ formatMachineStatusLines({
83
+ memPct: 62,
84
+ cpuPct: 31,
85
+ diskPct: 7,
86
+ load: [1.2, 0.98, 0.75],
87
+ cycleMinutes: 14,
88
+ }),
89
+ ).toEqual(['M62% C31% D7% cy14', 'LA 1 1 1']);
90
+ });
91
+
92
+ it('falls back to placeholders when the machine status is absent', () => {
93
+ expect(formatMachineStatusLines(null)).toEqual([
94
+ 'M?% C?% D?% cy-',
95
+ 'LA ? ? ?',
96
+ ]);
97
+ });
98
+
99
+ it('renders cy- when cycle minutes is null', () => {
100
+ expect(
101
+ formatMachineStatusLines({
102
+ memPct: 1,
103
+ cpuPct: 2,
104
+ diskPct: 3,
105
+ load: [0, 0, 0],
106
+ cycleMinutes: null,
107
+ }),
108
+ ).toEqual(['M1% C2% D3% cy-', 'LA 0 0 0']);
109
+ });
110
+
111
+ it('renders D?% when only the disk percent is unavailable', () => {
112
+ expect(
113
+ formatMachineStatusLines({
114
+ memPct: 55,
115
+ cpuPct: 62,
116
+ diskPct: null,
117
+ load: [16, 23, 40],
118
+ cycleMinutes: 13,
119
+ }),
120
+ ).toEqual(['M55% C62% D?% cy13', 'LA 16 23 40']);
121
+ });
122
+
123
+ it('keeps both lines within the 32 character width budget at worst case', () => {
124
+ const lines = formatMachineStatusLines({
125
+ memPct: 100,
126
+ cpuPct: 100,
127
+ diskPct: 100,
128
+ load: [108.5, 120.25, 95.1],
129
+ cycleMinutes: 999,
130
+ });
131
+ expect(lines).toEqual(['M100% C100% D100% cy999', 'LA 108 120 95']);
132
+ for (const line of lines) {
133
+ expect(codePointLength(line)).toBeLessThanOrEqual(
134
+ PROJECT_ROW_WIDTH_BUDGET,
135
+ );
136
+ }
137
+ });
138
+ });
139
+
140
+ describe('formatProjectHeaderLine', () => {
141
+ it('renders the fixed project grid header', () => {
142
+ expect(formatProjectHeaderLine()).toBe('pj unr tdo aqc fal prp aws dep');
143
+ });
144
+
145
+ it('fits the 32 character width budget exactly', () => {
146
+ expect(codePointLength(formatProjectHeaderLine())).toBe(
147
+ PROJECT_ROW_WIDTH_BUDGET,
148
+ );
149
+ });
150
+ });
151
+
152
+ describe('formatProjectRowLine', () => {
153
+ it('renders a present row with its severity dot and counts', () => {
154
+ expect(
155
+ formatProjectRowLine({
156
+ code: 'um',
157
+ row: projectRow({ unread: 3, todo: 1, qc: 2, ws: 4, dep: 1 }),
158
+ }),
159
+ ).toBe('🟢um 3 1 2 0 0 4 1');
160
+ });
161
+
162
+ it('renders placeholder cells with a blank dot for an absent project file', () => {
163
+ expect(formatProjectRowLine({ code: 'xc', row: null })).toBe(
164
+ ' xc -- -- -- -- -- -- --',
165
+ );
166
+ });
167
+
168
+ it('caps a count above 999 at 999', () => {
169
+ expect(
170
+ formatProjectRowLine({ code: 'um', row: projectRow({ unread: 1500 }) }),
171
+ ).toBe('🟠um 999 0 0 0 0 0 0');
172
+ });
173
+
174
+ it('applies the five level severity dot rules in descending order', () => {
175
+ expect(
176
+ formatProjectRowLine({ code: 'um', row: projectRow({ blocker: 2 }) }),
177
+ ).toContain('🔴');
178
+ expect(
179
+ formatProjectRowLine({ code: 'um', row: projectRow({ blocker: 1 }) }),
180
+ ).toContain('🟣');
181
+ expect(
182
+ formatProjectRowLine({ code: 'um', row: projectRow({ unread: 10 }) }),
183
+ ).toContain('🟠');
184
+ expect(
185
+ formatProjectRowLine({ code: 'um', row: projectRow({ qc: 15 }) }),
186
+ ).toContain('🟠');
187
+ expect(
188
+ formatProjectRowLine({ code: 'um', row: projectRow({ fail: 5 }) }),
189
+ ).toContain('🟠');
190
+ expect(
191
+ formatProjectRowLine({ code: 'um', row: projectRow({ unread: 5 }) }),
192
+ ).toContain('🟡');
193
+ expect(
194
+ formatProjectRowLine({ code: 'um', row: projectRow({ qc: 10 }) }),
195
+ ).toContain('🟡');
196
+ expect(
197
+ formatProjectRowLine({ code: 'um', row: projectRow({ fail: 3 }) }),
198
+ ).toContain('🟡');
199
+ expect(
200
+ formatProjectRowLine({
201
+ code: 'um',
202
+ row: projectRow({ unread: 4, qc: 9, fail: 2 }),
203
+ }),
204
+ ).toContain('🟢');
205
+ });
206
+
207
+ it('keeps present and absent rows within the 32 code point width budget', () => {
208
+ const present = formatProjectRowLine({
209
+ code: 'xm',
210
+ row: projectRow({
211
+ unread: 999,
212
+ todo: 999,
213
+ qc: 999,
214
+ fail: 999,
215
+ pr: 999,
216
+ ws: 999,
217
+ dep: 999,
218
+ }),
219
+ });
220
+ const absent = formatProjectRowLine({ code: 'xc', row: null });
221
+ expect(codePointLength(present)).toBeLessThanOrEqual(
222
+ PROJECT_ROW_WIDTH_BUDGET,
223
+ );
224
+ expect(codePointLength(absent)).toBeLessThanOrEqual(
225
+ PROJECT_ROW_WIDTH_BUDGET,
226
+ );
227
+ });
228
+ });
229
+
230
+ describe('formatTokenRowLine', () => {
231
+ it('renders a token row with utilization, reset countdown, prep and hum', () => {
232
+ expect(
233
+ formatTokenRowLine(
234
+ tokenStatus({
235
+ name: 'alice',
236
+ fiveHourUtilizationPercent: 10,
237
+ fiveHourResetSeconds: 3600,
238
+ sevenDayUtilizationPercent: 12,
239
+ sevenDayResetSeconds: 86400 * 5,
240
+ color: 'G',
241
+ prep: 2,
242
+ hum: 1,
243
+ }),
244
+ ),
245
+ ).toBe('🟢alice 10% 0d01h00 12% 5d00h00 2 1');
246
+ });
247
+
248
+ it('pads short names to four characters with underscores', () => {
249
+ expect(
250
+ formatTokenRowLine(
251
+ tokenStatus({
252
+ name: 'bob',
253
+ fiveHourUtilizationPercent: 100,
254
+ fiveHourResetSeconds: 0,
255
+ sevenDayUtilizationPercent: 95,
256
+ sevenDayResetSeconds: 7200,
257
+ color: 'K',
258
+ }),
259
+ ),
260
+ ).toBe('⚪bob_ 100% 0d00h00 95% 0d02h00 0 0');
261
+ });
262
+
263
+ it('renders question marks when window data is unavailable', () => {
264
+ expect(
265
+ formatTokenRowLine(
266
+ tokenStatus({
267
+ name: 'carolxx',
268
+ fiveHourUtilizationPercent: null,
269
+ fiveHourResetSeconds: null,
270
+ sevenDayUtilizationPercent: null,
271
+ sevenDayResetSeconds: null,
272
+ color: 'Y',
273
+ prep: 1,
274
+ hum: 0,
275
+ }),
276
+ ),
277
+ ).toBe('🟡carolxx ? ? ? ? 1 0');
278
+ });
279
+ });
280
+
281
+ describe('ComposeDashboardUseCase', () => {
282
+ const representativeInput: ComposeDashboardInput = {
283
+ machineStatus: {
284
+ memPct: 55,
285
+ cpuPct: 62,
286
+ diskPct: 89,
287
+ load: [16, 23, 40],
288
+ cycleMinutes: 14,
289
+ },
290
+ projects: [
291
+ {
292
+ code: 'um',
293
+ row: projectRow({ unread: 3, todo: 1, qc: 2, ws: 4, dep: 1 }),
294
+ },
295
+ {
296
+ code: 'xm',
297
+ row: projectRow({ unread: 12, qc: 16, fail: 6, pr: 1 }),
298
+ },
299
+ { code: 'xc', row: null },
300
+ { code: 'ut', row: projectRow({ blocker: 1 }) },
301
+ ],
302
+ tokens: [
303
+ tokenStatus({
304
+ name: 'alice',
305
+ fiveHourUtilizationPercent: 10,
306
+ fiveHourResetSeconds: 3600,
307
+ sevenDayUtilizationPercent: 12,
308
+ sevenDayResetSeconds: 86400 * 5,
309
+ color: 'G',
310
+ prep: 2,
311
+ hum: 1,
312
+ }),
313
+ tokenStatus({
314
+ name: 'bob',
315
+ fiveHourUtilizationPercent: 100,
316
+ fiveHourResetSeconds: 0,
317
+ sevenDayUtilizationPercent: 95,
318
+ sevenDayResetSeconds: 7200,
319
+ color: 'K',
320
+ prep: 0,
321
+ hum: 0,
322
+ }),
323
+ tokenStatus({
324
+ name: 'carolxx',
325
+ fiveHourUtilizationPercent: null,
326
+ fiveHourResetSeconds: null,
327
+ sevenDayUtilizationPercent: null,
328
+ sevenDayResetSeconds: null,
329
+ color: 'Y',
330
+ prep: 1,
331
+ hum: 0,
332
+ }),
333
+ ],
334
+ };
335
+
336
+ const expectedBody =
337
+ '<tt>M55%&nbsp;C62%&nbsp;D89%&nbsp;cy14</tt><br>\n' +
338
+ '<tt>LA&nbsp;16&nbsp;23&nbsp;40</tt><br>\n' +
339
+ '<tt>pj&nbsp;&nbsp;&nbsp;unr&nbsp;tdo&nbsp;aqc&nbsp;fal&nbsp;prp&nbsp;aws&nbsp;dep</tt><br>\n' +
340
+ '<tt>🟢um&nbsp;&nbsp;&nbsp;3&nbsp;&nbsp;&nbsp;1&nbsp;&nbsp;&nbsp;2&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;4&nbsp;&nbsp;&nbsp;1</tt><br>\n' +
341
+ '<tt>🟠xm&nbsp;&nbsp;12&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;16&nbsp;&nbsp;&nbsp;6&nbsp;&nbsp;&nbsp;1&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;0</tt><br>\n' +
342
+ '<tt>&nbsp;&nbsp;xc&nbsp;&nbsp;--&nbsp;&nbsp;--&nbsp;&nbsp;--&nbsp;&nbsp;--&nbsp;&nbsp;--&nbsp;&nbsp;--&nbsp;&nbsp;--</tt><br>\n' +
343
+ '<tt>🟣ut&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;0</tt><br>\n' +
344
+ '<tt></tt><br>\n' +
345
+ '<tt>⚪bob_&nbsp;100%&nbsp;0d00h00&nbsp;&nbsp;95%&nbsp;0d02h00&nbsp;0&nbsp;0</tt><br>\n' +
346
+ '<tt>🟢alice&nbsp;&nbsp;10%&nbsp;0d01h00&nbsp;&nbsp;12%&nbsp;5d00h00&nbsp;2&nbsp;1</tt><br>\n' +
347
+ '<tt>🟡carolxx&nbsp;&nbsp;&nbsp;&nbsp;?&nbsp;?&nbsp;&nbsp;&nbsp;&nbsp;?&nbsp;?&nbsp;1&nbsp;0</tt><br>\n';
348
+
349
+ it('composes byte-identical dashboard text for representative inputs', () => {
350
+ expect(new ComposeDashboardUseCase().run(representativeInput)).toBe(
351
+ expectedBody,
352
+ );
353
+ });
354
+
355
+ it('sorts token rows by seven day reset ascending with null resets last', () => {
356
+ const output = new ComposeDashboardUseCase().run(representativeInput);
357
+ const bobIndex = output.indexOf('⚪bob_');
358
+ const aliceIndex = output.indexOf('🟢alice');
359
+ const carolIndex = output.indexOf('🟡carolxx');
360
+ expect(bobIndex).toBeLessThan(aliceIndex);
361
+ expect(aliceIndex).toBeLessThan(carolIndex);
362
+ });
363
+
364
+ it('preserves input order for tokens with equal seven day reset', () => {
365
+ const output = new ComposeDashboardUseCase().run({
366
+ machineStatus: null,
367
+ projects: [],
368
+ tokens: [
369
+ tokenStatus({ name: 'first', sevenDayResetSeconds: 100 }),
370
+ tokenStatus({ name: 'second', sevenDayResetSeconds: 100 }),
371
+ tokenStatus({ name: 'third', sevenDayResetSeconds: 100 }),
372
+ ],
373
+ });
374
+ expect(output.indexOf('first')).toBeLessThan(output.indexOf('second'));
375
+ expect(output.indexOf('second')).toBeLessThan(output.indexOf('third'));
376
+ });
377
+
378
+ it('renders host placeholders when the machine status file is absent', () => {
379
+ const output = new ComposeDashboardUseCase().run({
380
+ ...representativeInput,
381
+ machineStatus: null,
382
+ });
383
+ expect(
384
+ output.startsWith(
385
+ '<tt>M?%&nbsp;C?%&nbsp;D?%&nbsp;cy-</tt><br>\n' +
386
+ '<tt>LA&nbsp;?&nbsp;?&nbsp;?</tt><br>\n',
387
+ ),
388
+ ).toBe(true);
389
+ });
390
+
391
+ it('keeps every unwrapped composed line within the 32 code point width budget', () => {
392
+ const denseInput: ComposeDashboardInput = {
393
+ machineStatus: {
394
+ memPct: 100,
395
+ cpuPct: 100,
396
+ diskPct: 100,
397
+ load: [108.5, 120.25, 95.1],
398
+ cycleMinutes: 999,
399
+ },
400
+ projects: [
401
+ {
402
+ code: 'um',
403
+ row: projectRow({
404
+ unread: 999,
405
+ todo: 999,
406
+ qc: 999,
407
+ fail: 999,
408
+ pr: 999,
409
+ ws: 999,
410
+ dep: 999,
411
+ }),
412
+ },
413
+ ],
414
+ tokens: [],
415
+ };
416
+ const output = new ComposeDashboardUseCase().run(denseInput);
417
+ const unwrappedLines = output
418
+ .split('\n')
419
+ .filter((line) => line.length > 0)
420
+ .map((line) =>
421
+ line
422
+ .replace(/^<tt>/, '')
423
+ .replace(/<\/tt><br>$/, '')
424
+ .replace(/&nbsp;/g, ' '),
425
+ );
426
+ for (const line of unwrappedLines) {
427
+ expect(codePointLength(line)).toBeLessThanOrEqual(
428
+ PROJECT_ROW_WIDTH_BUDGET,
429
+ );
430
+ }
431
+ });
432
+ });