github-issue-tower-defence-management 1.105.0 → 1.107.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.
- package/CHANGELOG.md +14 -0
- package/README.md +5 -0
- package/bin/adapter/entry-points/cli/index.js +18 -1
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/dashboardComposeService.js +178 -0
- package/bin/adapter/entry-points/console/dashboardComposeService.js.map +1 -0
- package/bin/adapter/entry-points/console/webServer.js +29 -7
- package/bin/adapter/entry-points/console/webServer.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +13 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js +18 -0
- package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js.map +1 -0
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js +22 -0
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
- package/bin/domain/entities/LiveTmuxSession.js +3 -0
- package/bin/domain/entities/LiveTmuxSession.js.map +1 -0
- package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js +58 -0
- package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js.map +1 -0
- package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js +161 -0
- package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.ts +36 -2
- package/src/adapter/entry-points/console/dashboardComposeService.test.ts +443 -0
- package/src/adapter/entry-points/console/dashboardComposeService.ts +200 -0
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -0
- package/src/adapter/entry-points/console/webServer.test.ts +255 -54
- package/src/adapter/entry-points/console/webServer.ts +43 -10
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +17 -0
- package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +170 -0
- package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.ts +40 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +81 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.ts +35 -0
- package/src/domain/entities/LiveTmuxSession.ts +4 -0
- package/src/domain/usecases/StaleTmuxSessionKillUseCase.test.ts +286 -0
- package/src/domain/usecases/StaleTmuxSessionKillUseCase.ts +102 -0
- package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +4 -0
- package/src/domain/usecases/dashboard/ComposeDashboardUseCase.test.ts +405 -0
- package/src/domain/usecases/dashboard/ComposeDashboardUseCase.ts +220 -0
- package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +6 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/dashboardComposeService.d.ts +9 -0
- package/types/adapter/entry-points/console/dashboardComposeService.d.ts.map +1 -0
- package/types/adapter/entry-points/console/webServer.d.ts +4 -0
- package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts +12 -0
- package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts.map +1 -0
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +3 -0
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
- package/types/domain/entities/LiveTmuxSession.d.ts +5 -0
- package/types/domain/entities/LiveTmuxSession.d.ts.map +1 -0
- package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts +19 -0
- package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +3 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
- package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts +28 -0
- package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts.map +1 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Issue } from '../entities/Issue';
|
|
2
|
+
import { LiveTmuxSession } from '../entities/LiveTmuxSession';
|
|
3
|
+
import { Project } from '../entities/Project';
|
|
4
|
+
import { IN_TMUX_STATUS_NAME } from '../entities/WorkflowStatus';
|
|
5
|
+
import { IssueRepository } from './adapter-interfaces/IssueRepository';
|
|
6
|
+
import { TmuxSessionRepository } from './adapter-interfaces/TmuxSessionRepository';
|
|
7
|
+
import { toTmuxSessionName } from './intmux/InTmuxByHumanSessionReconcileUseCase';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_EXCLUDED_STATUS = IN_TMUX_STATUS_NAME;
|
|
10
|
+
export const DEFAULT_IDLE_THRESHOLD_SECONDS = 24 * 60 * 60;
|
|
11
|
+
|
|
12
|
+
type KillCandidate = {
|
|
13
|
+
sessionName: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export class StaleTmuxSessionKillUseCase {
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly issueRepository: Pick<IssueRepository, 'getAllOpened'>,
|
|
20
|
+
private readonly tmuxSessionRepository: Pick<
|
|
21
|
+
TmuxSessionRepository,
|
|
22
|
+
'listLiveSessionsWithActivity' | 'killSession'
|
|
23
|
+
>,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
run = async (params: {
|
|
27
|
+
project: Project;
|
|
28
|
+
allowCacheMinutes: number;
|
|
29
|
+
excludedStatus: string;
|
|
30
|
+
idleThresholdSeconds: number;
|
|
31
|
+
now: Date;
|
|
32
|
+
}): Promise<void> => {
|
|
33
|
+
const liveSessions =
|
|
34
|
+
await this.tmuxSessionRepository.listLiveSessionsWithActivity();
|
|
35
|
+
const openIssues = await this.issueRepository.getAllOpened(
|
|
36
|
+
params.project,
|
|
37
|
+
params.allowCacheMinutes,
|
|
38
|
+
);
|
|
39
|
+
const issueBySessionName = new Map<string, Issue>();
|
|
40
|
+
for (const issue of openIssues) {
|
|
41
|
+
issueBySessionName.set(toTmuxSessionName(issue.url), issue);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const nowEpochSeconds = Math.floor(params.now.getTime() / 1000);
|
|
45
|
+
const killCandidates: KillCandidate[] = [];
|
|
46
|
+
for (const session of liveSessions) {
|
|
47
|
+
const reason = this.evaluateKillReason(
|
|
48
|
+
session,
|
|
49
|
+
issueBySessionName.get(session.sessionName) ?? null,
|
|
50
|
+
nowEpochSeconds,
|
|
51
|
+
params.excludedStatus,
|
|
52
|
+
params.idleThresholdSeconds,
|
|
53
|
+
);
|
|
54
|
+
if (reason !== null) {
|
|
55
|
+
killCandidates.push({ sessionName: session.sessionName, reason });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log(
|
|
60
|
+
`Stale tmux session cleanup: ${killCandidates.length} kill candidate(s) of ${liveSessions.length} live session(s).`,
|
|
61
|
+
);
|
|
62
|
+
for (const candidate of killCandidates) {
|
|
63
|
+
console.log(
|
|
64
|
+
`Kill candidate: ${candidate.sessionName} (${candidate.reason})`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const candidate of killCandidates) {
|
|
69
|
+
await this.tmuxSessionRepository.killSession(candidate.sessionName);
|
|
70
|
+
console.log(
|
|
71
|
+
`Killed tmux session: ${candidate.sessionName} (${candidate.reason})`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
private evaluateKillReason = (
|
|
77
|
+
session: LiveTmuxSession,
|
|
78
|
+
issue: Issue | null,
|
|
79
|
+
nowEpochSeconds: number,
|
|
80
|
+
excludedStatus: string,
|
|
81
|
+
idleThresholdSeconds: number,
|
|
82
|
+
): string | null => {
|
|
83
|
+
if (issue !== null) {
|
|
84
|
+
if (issue.status !== excludedStatus) {
|
|
85
|
+
return `mapped to open issue ${issue.url} with status "${issue.status ?? 'null'}" which is not the excluded status "${excludedStatus}"`;
|
|
86
|
+
}
|
|
87
|
+
if (issue.nextActionDate !== null) {
|
|
88
|
+
return `mapped to open issue ${issue.url} which has a next action date set`;
|
|
89
|
+
}
|
|
90
|
+
if (issue.nextActionHour !== null) {
|
|
91
|
+
return `mapped to open issue ${issue.url} which has a next action hour set`;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const idleSeconds = nowEpochSeconds - session.activityEpochSeconds;
|
|
97
|
+
if (idleSeconds >= idleThresholdSeconds) {
|
|
98
|
+
return `maps to no open issue and has been idle for ${idleSeconds} seconds (threshold ${idleThresholdSeconds} seconds)`;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { LiveTmuxSession } from '../../entities/LiveTmuxSession';
|
|
2
|
+
|
|
1
3
|
export interface TmuxSessionRepository {
|
|
2
4
|
listLiveSessionNames: () => Promise<string[]>;
|
|
5
|
+
listLiveSessionsWithActivity: () => Promise<LiveTmuxSession[]>;
|
|
3
6
|
listInteractiveProcessCommandLines: () => Promise<string[]>;
|
|
4
7
|
launchDetachedSession: (
|
|
5
8
|
sessionName: string,
|
|
6
9
|
launcherCommand: string,
|
|
7
10
|
issueUrl: string,
|
|
8
11
|
) => Promise<void>;
|
|
12
|
+
killSession: (sessionName: string) => Promise<void>;
|
|
9
13
|
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ComposeDashboardInput,
|
|
3
|
+
ComposeDashboardUseCase,
|
|
4
|
+
PROJECT_ROW_WIDTH_BUDGET,
|
|
5
|
+
formatMachineStatusLine,
|
|
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('formatMachineStatusLine', () => {
|
|
68
|
+
it('renders the host-metrics line from a machine status', () => {
|
|
69
|
+
expect(
|
|
70
|
+
formatMachineStatusLine({
|
|
71
|
+
memPct: 55,
|
|
72
|
+
cpuPct: 62,
|
|
73
|
+
load: [16, 23, 40],
|
|
74
|
+
cycleMinutes: 14,
|
|
75
|
+
}),
|
|
76
|
+
).toBe('M55% C62% LA 16 23 40 cy14');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rounds loads with half-to-even and renders integers', () => {
|
|
80
|
+
expect(
|
|
81
|
+
formatMachineStatusLine({
|
|
82
|
+
memPct: 62,
|
|
83
|
+
cpuPct: 31,
|
|
84
|
+
load: [1.2, 0.98, 0.75],
|
|
85
|
+
cycleMinutes: 14,
|
|
86
|
+
}),
|
|
87
|
+
).toBe('M62% C31% LA 1 1 1 cy14');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('falls back to placeholders when the machine status is absent', () => {
|
|
91
|
+
expect(formatMachineStatusLine(null)).toBe('M?% C?% LA ? ? ? cy-');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('renders cy- when cycle minutes is null', () => {
|
|
95
|
+
expect(
|
|
96
|
+
formatMachineStatusLine({
|
|
97
|
+
memPct: 1,
|
|
98
|
+
cpuPct: 2,
|
|
99
|
+
load: [0, 0, 0],
|
|
100
|
+
cycleMinutes: null,
|
|
101
|
+
}),
|
|
102
|
+
).toBe('M1% C2% LA 0 0 0 cy-');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('stays within the 32 character width budget at worst case', () => {
|
|
106
|
+
const line = formatMachineStatusLine({
|
|
107
|
+
memPct: 100,
|
|
108
|
+
cpuPct: 100,
|
|
109
|
+
load: [108.5, 120.25, 95.1],
|
|
110
|
+
cycleMinutes: 999,
|
|
111
|
+
});
|
|
112
|
+
expect(line).toBe('M100% C100% LA 108 120 95 cy999');
|
|
113
|
+
expect(codePointLength(line)).toBeLessThanOrEqual(PROJECT_ROW_WIDTH_BUDGET);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('formatProjectHeaderLine', () => {
|
|
118
|
+
it('renders the fixed project grid header', () => {
|
|
119
|
+
expect(formatProjectHeaderLine()).toBe('pj unr tdo aqc fal prp aws dep');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('fits the 32 character width budget exactly', () => {
|
|
123
|
+
expect(codePointLength(formatProjectHeaderLine())).toBe(
|
|
124
|
+
PROJECT_ROW_WIDTH_BUDGET,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('formatProjectRowLine', () => {
|
|
130
|
+
it('renders a present row with its severity dot and counts', () => {
|
|
131
|
+
expect(
|
|
132
|
+
formatProjectRowLine({
|
|
133
|
+
code: 'um',
|
|
134
|
+
row: projectRow({ unread: 3, todo: 1, qc: 2, ws: 4, dep: 1 }),
|
|
135
|
+
}),
|
|
136
|
+
).toBe('🟢um 3 1 2 0 0 4 1');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('renders placeholder cells with a blank dot for an absent project file', () => {
|
|
140
|
+
expect(formatProjectRowLine({ code: 'xc', row: null })).toBe(
|
|
141
|
+
' xc -- -- -- -- -- -- --',
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('caps a count above 999 at 999', () => {
|
|
146
|
+
expect(
|
|
147
|
+
formatProjectRowLine({ code: 'um', row: projectRow({ unread: 1500 }) }),
|
|
148
|
+
).toBe('🟠um 999 0 0 0 0 0 0');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('applies the five level severity dot rules in descending order', () => {
|
|
152
|
+
expect(
|
|
153
|
+
formatProjectRowLine({ code: 'um', row: projectRow({ blocker: 2 }) }),
|
|
154
|
+
).toContain('🔴');
|
|
155
|
+
expect(
|
|
156
|
+
formatProjectRowLine({ code: 'um', row: projectRow({ blocker: 1 }) }),
|
|
157
|
+
).toContain('🟣');
|
|
158
|
+
expect(
|
|
159
|
+
formatProjectRowLine({ code: 'um', row: projectRow({ unread: 10 }) }),
|
|
160
|
+
).toContain('🟠');
|
|
161
|
+
expect(
|
|
162
|
+
formatProjectRowLine({ code: 'um', row: projectRow({ qc: 15 }) }),
|
|
163
|
+
).toContain('🟠');
|
|
164
|
+
expect(
|
|
165
|
+
formatProjectRowLine({ code: 'um', row: projectRow({ fail: 5 }) }),
|
|
166
|
+
).toContain('🟠');
|
|
167
|
+
expect(
|
|
168
|
+
formatProjectRowLine({ code: 'um', row: projectRow({ unread: 5 }) }),
|
|
169
|
+
).toContain('🟡');
|
|
170
|
+
expect(
|
|
171
|
+
formatProjectRowLine({ code: 'um', row: projectRow({ qc: 10 }) }),
|
|
172
|
+
).toContain('🟡');
|
|
173
|
+
expect(
|
|
174
|
+
formatProjectRowLine({ code: 'um', row: projectRow({ fail: 3 }) }),
|
|
175
|
+
).toContain('🟡');
|
|
176
|
+
expect(
|
|
177
|
+
formatProjectRowLine({
|
|
178
|
+
code: 'um',
|
|
179
|
+
row: projectRow({ unread: 4, qc: 9, fail: 2 }),
|
|
180
|
+
}),
|
|
181
|
+
).toContain('🟢');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('keeps present and absent rows within the 32 code point width budget', () => {
|
|
185
|
+
const present = formatProjectRowLine({
|
|
186
|
+
code: 'xm',
|
|
187
|
+
row: projectRow({
|
|
188
|
+
unread: 999,
|
|
189
|
+
todo: 999,
|
|
190
|
+
qc: 999,
|
|
191
|
+
fail: 999,
|
|
192
|
+
pr: 999,
|
|
193
|
+
ws: 999,
|
|
194
|
+
dep: 999,
|
|
195
|
+
}),
|
|
196
|
+
});
|
|
197
|
+
const absent = formatProjectRowLine({ code: 'xc', row: null });
|
|
198
|
+
expect(codePointLength(present)).toBeLessThanOrEqual(
|
|
199
|
+
PROJECT_ROW_WIDTH_BUDGET,
|
|
200
|
+
);
|
|
201
|
+
expect(codePointLength(absent)).toBeLessThanOrEqual(
|
|
202
|
+
PROJECT_ROW_WIDTH_BUDGET,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('formatTokenRowLine', () => {
|
|
208
|
+
it('renders a token row with utilization, reset countdown, prep and hum', () => {
|
|
209
|
+
expect(
|
|
210
|
+
formatTokenRowLine(
|
|
211
|
+
tokenStatus({
|
|
212
|
+
name: 'alice',
|
|
213
|
+
fiveHourUtilizationPercent: 10,
|
|
214
|
+
fiveHourResetSeconds: 3600,
|
|
215
|
+
sevenDayUtilizationPercent: 12,
|
|
216
|
+
sevenDayResetSeconds: 86400 * 5,
|
|
217
|
+
color: 'G',
|
|
218
|
+
prep: 2,
|
|
219
|
+
hum: 1,
|
|
220
|
+
}),
|
|
221
|
+
),
|
|
222
|
+
).toBe('🟢alice 10% 0d01h00 12% 5d00h00 2 1');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('pads short names to four characters with underscores', () => {
|
|
226
|
+
expect(
|
|
227
|
+
formatTokenRowLine(
|
|
228
|
+
tokenStatus({
|
|
229
|
+
name: 'bob',
|
|
230
|
+
fiveHourUtilizationPercent: 100,
|
|
231
|
+
fiveHourResetSeconds: 0,
|
|
232
|
+
sevenDayUtilizationPercent: 95,
|
|
233
|
+
sevenDayResetSeconds: 7200,
|
|
234
|
+
color: 'K',
|
|
235
|
+
}),
|
|
236
|
+
),
|
|
237
|
+
).toBe('⚪bob_ 100% 0d00h00 95% 0d02h00 0 0');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('renders question marks when window data is unavailable', () => {
|
|
241
|
+
expect(
|
|
242
|
+
formatTokenRowLine(
|
|
243
|
+
tokenStatus({
|
|
244
|
+
name: 'carolxx',
|
|
245
|
+
fiveHourUtilizationPercent: null,
|
|
246
|
+
fiveHourResetSeconds: null,
|
|
247
|
+
sevenDayUtilizationPercent: null,
|
|
248
|
+
sevenDayResetSeconds: null,
|
|
249
|
+
color: 'Y',
|
|
250
|
+
prep: 1,
|
|
251
|
+
hum: 0,
|
|
252
|
+
}),
|
|
253
|
+
),
|
|
254
|
+
).toBe('🟡carolxx ? ? ? ? 1 0');
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe('ComposeDashboardUseCase', () => {
|
|
259
|
+
const representativeInput: ComposeDashboardInput = {
|
|
260
|
+
machineStatus: {
|
|
261
|
+
memPct: 55,
|
|
262
|
+
cpuPct: 62,
|
|
263
|
+
load: [16, 23, 40],
|
|
264
|
+
cycleMinutes: 14,
|
|
265
|
+
},
|
|
266
|
+
projects: [
|
|
267
|
+
{
|
|
268
|
+
code: 'um',
|
|
269
|
+
row: projectRow({ unread: 3, todo: 1, qc: 2, ws: 4, dep: 1 }),
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
code: 'xm',
|
|
273
|
+
row: projectRow({ unread: 12, qc: 16, fail: 6, pr: 1 }),
|
|
274
|
+
},
|
|
275
|
+
{ code: 'xc', row: null },
|
|
276
|
+
{ code: 'ut', row: projectRow({ blocker: 1 }) },
|
|
277
|
+
],
|
|
278
|
+
tokens: [
|
|
279
|
+
tokenStatus({
|
|
280
|
+
name: 'alice',
|
|
281
|
+
fiveHourUtilizationPercent: 10,
|
|
282
|
+
fiveHourResetSeconds: 3600,
|
|
283
|
+
sevenDayUtilizationPercent: 12,
|
|
284
|
+
sevenDayResetSeconds: 86400 * 5,
|
|
285
|
+
color: 'G',
|
|
286
|
+
prep: 2,
|
|
287
|
+
hum: 1,
|
|
288
|
+
}),
|
|
289
|
+
tokenStatus({
|
|
290
|
+
name: 'bob',
|
|
291
|
+
fiveHourUtilizationPercent: 100,
|
|
292
|
+
fiveHourResetSeconds: 0,
|
|
293
|
+
sevenDayUtilizationPercent: 95,
|
|
294
|
+
sevenDayResetSeconds: 7200,
|
|
295
|
+
color: 'K',
|
|
296
|
+
prep: 0,
|
|
297
|
+
hum: 0,
|
|
298
|
+
}),
|
|
299
|
+
tokenStatus({
|
|
300
|
+
name: 'carolxx',
|
|
301
|
+
fiveHourUtilizationPercent: null,
|
|
302
|
+
fiveHourResetSeconds: null,
|
|
303
|
+
sevenDayUtilizationPercent: null,
|
|
304
|
+
sevenDayResetSeconds: null,
|
|
305
|
+
color: 'Y',
|
|
306
|
+
prep: 1,
|
|
307
|
+
hum: 0,
|
|
308
|
+
}),
|
|
309
|
+
],
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const expectedBody =
|
|
313
|
+
'<tt>M55% C62% LA 16 23 40 cy14</tt><br>\n' +
|
|
314
|
+
'<tt>pj unr tdo aqc fal prp aws dep</tt><br>\n' +
|
|
315
|
+
'<tt>🟢um 3 1 2 0 0 4 1</tt><br>\n' +
|
|
316
|
+
'<tt>🟠xm 12 0 16 6 1 0 0</tt><br>\n' +
|
|
317
|
+
'<tt> xc -- -- -- -- -- -- --</tt><br>\n' +
|
|
318
|
+
'<tt>🟣ut 0 0 0 0 0 0 0</tt><br>\n' +
|
|
319
|
+
'<tt></tt><br>\n' +
|
|
320
|
+
'<tt>⚪bob_ 100% 0d00h00 95% 0d02h00 0 0</tt><br>\n' +
|
|
321
|
+
'<tt>🟢alice 10% 0d01h00 12% 5d00h00 2 1</tt><br>\n' +
|
|
322
|
+
'<tt>🟡carolxx ? ? ? ? 1 0</tt><br>\n';
|
|
323
|
+
|
|
324
|
+
it('composes byte-identical dashboard text for representative inputs', () => {
|
|
325
|
+
expect(new ComposeDashboardUseCase().run(representativeInput)).toBe(
|
|
326
|
+
expectedBody,
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('sorts token rows by seven day reset ascending with null resets last', () => {
|
|
331
|
+
const output = new ComposeDashboardUseCase().run(representativeInput);
|
|
332
|
+
const bobIndex = output.indexOf('⚪bob_');
|
|
333
|
+
const aliceIndex = output.indexOf('🟢alice');
|
|
334
|
+
const carolIndex = output.indexOf('🟡carolxx');
|
|
335
|
+
expect(bobIndex).toBeLessThan(aliceIndex);
|
|
336
|
+
expect(aliceIndex).toBeLessThan(carolIndex);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('preserves input order for tokens with equal seven day reset', () => {
|
|
340
|
+
const output = new ComposeDashboardUseCase().run({
|
|
341
|
+
machineStatus: null,
|
|
342
|
+
projects: [],
|
|
343
|
+
tokens: [
|
|
344
|
+
tokenStatus({ name: 'first', sevenDayResetSeconds: 100 }),
|
|
345
|
+
tokenStatus({ name: 'second', sevenDayResetSeconds: 100 }),
|
|
346
|
+
tokenStatus({ name: 'third', sevenDayResetSeconds: 100 }),
|
|
347
|
+
],
|
|
348
|
+
});
|
|
349
|
+
expect(output.indexOf('first')).toBeLessThan(output.indexOf('second'));
|
|
350
|
+
expect(output.indexOf('second')).toBeLessThan(output.indexOf('third'));
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('renders host placeholders when the machine status file is absent', () => {
|
|
354
|
+
const output = new ComposeDashboardUseCase().run({
|
|
355
|
+
...representativeInput,
|
|
356
|
+
machineStatus: null,
|
|
357
|
+
});
|
|
358
|
+
expect(
|
|
359
|
+
output.startsWith(
|
|
360
|
+
'<tt>M?% C?% LA ? ? ? cy-</tt><br>\n',
|
|
361
|
+
),
|
|
362
|
+
).toBe(true);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('keeps every unwrapped composed line within the 32 code point width budget', () => {
|
|
366
|
+
const denseInput: ComposeDashboardInput = {
|
|
367
|
+
machineStatus: {
|
|
368
|
+
memPct: 100,
|
|
369
|
+
cpuPct: 100,
|
|
370
|
+
load: [108.5, 120.25, 95.1],
|
|
371
|
+
cycleMinutes: 999,
|
|
372
|
+
},
|
|
373
|
+
projects: [
|
|
374
|
+
{
|
|
375
|
+
code: 'um',
|
|
376
|
+
row: projectRow({
|
|
377
|
+
unread: 999,
|
|
378
|
+
todo: 999,
|
|
379
|
+
qc: 999,
|
|
380
|
+
fail: 999,
|
|
381
|
+
pr: 999,
|
|
382
|
+
ws: 999,
|
|
383
|
+
dep: 999,
|
|
384
|
+
}),
|
|
385
|
+
},
|
|
386
|
+
],
|
|
387
|
+
tokens: [],
|
|
388
|
+
};
|
|
389
|
+
const output = new ComposeDashboardUseCase().run(denseInput);
|
|
390
|
+
const unwrappedLines = output
|
|
391
|
+
.split('\n')
|
|
392
|
+
.filter((line) => line.length > 0)
|
|
393
|
+
.map((line) =>
|
|
394
|
+
line
|
|
395
|
+
.replace(/^<tt>/, '')
|
|
396
|
+
.replace(/<\/tt><br>$/, '')
|
|
397
|
+
.replace(/ /g, ' '),
|
|
398
|
+
);
|
|
399
|
+
for (const line of unwrappedLines) {
|
|
400
|
+
expect(codePointLength(line)).toBeLessThanOrEqual(
|
|
401
|
+
PROJECT_ROW_WIDTH_BUDGET,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
});
|