oh-my-opencode-dashboard 0.0.3 → 0.0.4
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/dist/assets/index-CZM2MUUs.js +40 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/App.tsx +154 -42
- package/src/app-payload.test.ts +158 -0
- package/src/background-task-timeline.test.ts +32 -0
- package/src/ingest/background-tasks.test.ts +295 -2
- package/src/ingest/background-tasks.ts +67 -28
- package/src/ingest/model.ts +79 -0
- package/src/ingest/session.test.ts +119 -0
- package/src/ingest/session.ts +4 -0
- package/src/server/api.test.ts +2 -2
- package/src/server/dashboard.test.ts +139 -0
- package/src/server/dashboard.ts +38 -3
- package/src/timeseries-stacked.test.ts +261 -0
- package/src/timeseries-stacked.ts +145 -0
- package/dist/assets/index-Cs5xePn_.js +0 -40
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
pickActiveSessionId,
|
|
9
9
|
readMainSessionMetas,
|
|
10
10
|
} from "./session"
|
|
11
|
+
import { extractModelString, pickLatestModelString } from "./model"
|
|
11
12
|
|
|
12
13
|
function mkStorageRoot(): string {
|
|
13
14
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "omo-storage-"))
|
|
@@ -217,4 +218,122 @@ describe("getMainSessionView", () => {
|
|
|
217
218
|
|
|
218
219
|
expect(view.status).toBe("thinking")
|
|
219
220
|
})
|
|
221
|
+
|
|
222
|
+
it("returns null currentModel when no model metadata exists", () => {
|
|
223
|
+
const storageRoot = mkStorageRoot()
|
|
224
|
+
const storage = getStorageRoots(storageRoot)
|
|
225
|
+
const projectRoot = "/tmp/project"
|
|
226
|
+
const sessionId = "ses_1"
|
|
227
|
+
|
|
228
|
+
const messageDir = path.join(storage.message, sessionId)
|
|
229
|
+
fs.mkdirSync(messageDir, { recursive: true })
|
|
230
|
+
|
|
231
|
+
const messageID = "msg_1"
|
|
232
|
+
fs.writeFileSync(
|
|
233
|
+
path.join(messageDir, `${messageID}.json`),
|
|
234
|
+
JSON.stringify({
|
|
235
|
+
id: messageID,
|
|
236
|
+
sessionID: sessionId,
|
|
237
|
+
role: "assistant",
|
|
238
|
+
agent: "sisyphus",
|
|
239
|
+
time: { created: 1000 },
|
|
240
|
+
}),
|
|
241
|
+
"utf8"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
// #given no model metadata in messages
|
|
245
|
+
// #when deriving main session view
|
|
246
|
+
const view = getMainSessionView({
|
|
247
|
+
projectRoot,
|
|
248
|
+
sessionId,
|
|
249
|
+
storage,
|
|
250
|
+
sessionMeta: null,
|
|
251
|
+
nowMs: 50_000,
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// #then currentModel is null
|
|
255
|
+
expect(view.currentModel).toBeNull()
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
describe("extractModelString", () => {
|
|
260
|
+
it("supports assistant flat and user nested shapes", () => {
|
|
261
|
+
// #given assistant flat model
|
|
262
|
+
const assistantMeta = {
|
|
263
|
+
role: "assistant",
|
|
264
|
+
providerID: "openai",
|
|
265
|
+
modelID: "gpt-5.2",
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// #when extracting model string
|
|
269
|
+
const assistantModel = extractModelString(assistantMeta)
|
|
270
|
+
|
|
271
|
+
// #then uses provider/model format
|
|
272
|
+
expect(assistantModel).toBe("openai/gpt-5.2")
|
|
273
|
+
|
|
274
|
+
// #given user nested model
|
|
275
|
+
const userMeta = {
|
|
276
|
+
role: "user",
|
|
277
|
+
model: { providerID: "anthropic", modelID: "claude-opus-4.5" },
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// #when extracting model string
|
|
281
|
+
const userModel = extractModelString(userMeta)
|
|
282
|
+
|
|
283
|
+
// #then uses provider/model format
|
|
284
|
+
expect(userModel).toBe("anthropic/claude-opus-4.5")
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe("pickLatestModelString", () => {
|
|
289
|
+
it("prefers latest assistant model over newer user model", () => {
|
|
290
|
+
const metas = [
|
|
291
|
+
{
|
|
292
|
+
id: "msg_1",
|
|
293
|
+
role: "assistant",
|
|
294
|
+
time: { created: 1000 },
|
|
295
|
+
providerID: "openai",
|
|
296
|
+
modelID: "gpt-5.2",
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
id: "msg_2",
|
|
300
|
+
role: "user",
|
|
301
|
+
time: { created: 2000 },
|
|
302
|
+
model: { providerID: "anthropic", modelID: "claude-opus-4.5" },
|
|
303
|
+
},
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
// #given assistant with model exists but user is newer
|
|
307
|
+
// #when picking latest model string
|
|
308
|
+
const picked = pickLatestModelString(metas)
|
|
309
|
+
|
|
310
|
+
// #then assistant model is preferred
|
|
311
|
+
expect(picked).toBe("openai/gpt-5.2")
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it("uses deterministic ordering by created then id", () => {
|
|
315
|
+
const metas = [
|
|
316
|
+
{
|
|
317
|
+
id: "msg_a",
|
|
318
|
+
role: "assistant",
|
|
319
|
+
time: { created: 3000 },
|
|
320
|
+
providerID: "openai",
|
|
321
|
+
modelID: "gpt-5.2",
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
id: "msg_b",
|
|
325
|
+
role: "assistant",
|
|
326
|
+
time: { created: 3000 },
|
|
327
|
+
providerID: "openai",
|
|
328
|
+
modelID: "gpt-5.2-turbo",
|
|
329
|
+
},
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
// #given same created time, higher id wins
|
|
333
|
+
// #when picking latest model string
|
|
334
|
+
const picked = pickLatestModelString(metas)
|
|
335
|
+
|
|
336
|
+
// #then picks model from msg_b
|
|
337
|
+
expect(picked).toBe("openai/gpt-5.2-turbo")
|
|
338
|
+
})
|
|
220
339
|
})
|
package/src/ingest/session.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs"
|
|
2
2
|
import * as path from "node:path"
|
|
3
|
+
import { pickLatestModelString } from "./model"
|
|
3
4
|
import { getOpenCodeStorageDir, realpathSafe } from "./paths"
|
|
4
5
|
|
|
5
6
|
export type SessionMetadata = {
|
|
@@ -32,6 +33,7 @@ export type StoredToolPart = {
|
|
|
32
33
|
export type MainSessionView = {
|
|
33
34
|
agent: string
|
|
34
35
|
currentTool: string | null
|
|
36
|
+
currentModel: string | null
|
|
35
37
|
lastUpdated: number | null
|
|
36
38
|
sessionLabel: string
|
|
37
39
|
status: "busy" | "idle" | "unknown" | "running_tool" | "thinking"
|
|
@@ -253,6 +255,7 @@ export function getMainSessionView(opts: {
|
|
|
253
255
|
// Scan recent messages for any in-flight tool parts
|
|
254
256
|
let activeTool: { tool: string; status: string } | null = null
|
|
255
257
|
const recentMetas = readRecentMessageMetas(messageDir, 200)
|
|
258
|
+
const currentModel = pickLatestModelString(recentMetas)
|
|
256
259
|
|
|
257
260
|
// Iterate newest → oldest, early-exit on first tool part with pending/running status
|
|
258
261
|
for (const meta of recentMetas) {
|
|
@@ -276,6 +279,7 @@ export function getMainSessionView(opts: {
|
|
|
276
279
|
return {
|
|
277
280
|
agent,
|
|
278
281
|
currentTool: activeTool?.tool ?? null,
|
|
282
|
+
currentModel,
|
|
279
283
|
lastUpdated,
|
|
280
284
|
sessionLabel,
|
|
281
285
|
status,
|
package/src/server/api.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ describe('API Routes', () => {
|
|
|
6
6
|
const api = createApi({
|
|
7
7
|
getSnapshot: () => ({
|
|
8
8
|
mainSession: { agent: "x", currentTool: "-", lastUpdatedLabel: "never", session: "s", statusPill: "idle" },
|
|
9
|
-
planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started" },
|
|
9
|
+
planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started", steps: [] },
|
|
10
10
|
backgroundTasks: [],
|
|
11
11
|
timeSeries: {
|
|
12
12
|
windowMs: 0,
|
|
@@ -29,7 +29,7 @@ describe('API Routes', () => {
|
|
|
29
29
|
const api = createApi({
|
|
30
30
|
getSnapshot: () => ({
|
|
31
31
|
mainSession: { agent: "x", currentTool: "-", lastUpdatedLabel: "never", session: "s", statusPill: "idle" },
|
|
32
|
-
planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started" },
|
|
32
|
+
planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started", steps: [] },
|
|
33
33
|
backgroundTasks: [{ id: "1", description: "d", agent: "a", status: "queued", toolCalls: 0, lastTool: "-", timeline: "" }],
|
|
34
34
|
timeSeries: {
|
|
35
35
|
windowMs: 0,
|
|
@@ -75,6 +75,7 @@ describe("buildDashboardPayload", () => {
|
|
|
75
75
|
expect(payload.mainSession.statusPill).toBe("running tool")
|
|
76
76
|
expect(payload.mainSession.currentTool).toBe("delegate_task")
|
|
77
77
|
expect(payload.mainSession.agent).toBe("sisyphus")
|
|
78
|
+
expect(payload.mainSession.currentModel).toBeNull()
|
|
78
79
|
|
|
79
80
|
expect(payload.raw).not.toHaveProperty("prompt")
|
|
80
81
|
expect(payload.raw).not.toHaveProperty("input")
|
|
@@ -147,6 +148,7 @@ describe("buildDashboardPayload", () => {
|
|
|
147
148
|
|
|
148
149
|
expect(payload).toHaveProperty("timeSeries")
|
|
149
150
|
expect(payload.raw).toHaveProperty("timeSeries")
|
|
151
|
+
expect(payload.mainSession.currentModel).toBeNull()
|
|
150
152
|
|
|
151
153
|
const sensitiveKeys = ["prompt", "input", "output", "error", "state"]
|
|
152
154
|
|
|
@@ -175,4 +177,141 @@ describe("buildDashboardPayload", () => {
|
|
|
175
177
|
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
176
178
|
}
|
|
177
179
|
})
|
|
180
|
+
|
|
181
|
+
it("includes latest model strings for main and background sessions", () => {
|
|
182
|
+
const storageRoot = mkStorageRoot()
|
|
183
|
+
const storage = getStorageRoots(storageRoot)
|
|
184
|
+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
|
|
185
|
+
const sessionId = "ses_with_models"
|
|
186
|
+
const backgroundSessionId = "ses_bg_1"
|
|
187
|
+
const messageId = "msg_1"
|
|
188
|
+
const projectID = "proj_1"
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const sessionMetaDir = path.join(storage.session, projectID)
|
|
192
|
+
fs.mkdirSync(sessionMetaDir, { recursive: true })
|
|
193
|
+
fs.writeFileSync(
|
|
194
|
+
path.join(sessionMetaDir, `${sessionId}.json`),
|
|
195
|
+
JSON.stringify({
|
|
196
|
+
id: sessionId,
|
|
197
|
+
projectID,
|
|
198
|
+
directory: projectRoot,
|
|
199
|
+
time: { created: 1000, updated: 1000 },
|
|
200
|
+
}),
|
|
201
|
+
"utf8"
|
|
202
|
+
)
|
|
203
|
+
fs.writeFileSync(
|
|
204
|
+
path.join(sessionMetaDir, `${backgroundSessionId}.json`),
|
|
205
|
+
JSON.stringify({
|
|
206
|
+
id: backgroundSessionId,
|
|
207
|
+
projectID,
|
|
208
|
+
directory: projectRoot,
|
|
209
|
+
parentID: sessionId,
|
|
210
|
+
title: "Background: model task",
|
|
211
|
+
time: { created: 1000, updated: 1100 },
|
|
212
|
+
}),
|
|
213
|
+
"utf8"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
const messageDir = path.join(storage.message, sessionId)
|
|
217
|
+
fs.mkdirSync(messageDir, { recursive: true })
|
|
218
|
+
fs.writeFileSync(
|
|
219
|
+
path.join(messageDir, `${messageId}.json`),
|
|
220
|
+
JSON.stringify({
|
|
221
|
+
id: messageId,
|
|
222
|
+
sessionID: sessionId,
|
|
223
|
+
role: "assistant",
|
|
224
|
+
agent: "sisyphus",
|
|
225
|
+
time: { created: 1000 },
|
|
226
|
+
}),
|
|
227
|
+
"utf8"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
const partDir = path.join(storage.part, messageId)
|
|
231
|
+
fs.mkdirSync(partDir, { recursive: true })
|
|
232
|
+
fs.writeFileSync(
|
|
233
|
+
path.join(partDir, "part_1.json"),
|
|
234
|
+
JSON.stringify({
|
|
235
|
+
id: "part_1",
|
|
236
|
+
sessionID: sessionId,
|
|
237
|
+
messageID: messageId,
|
|
238
|
+
type: "tool",
|
|
239
|
+
callID: "call_1",
|
|
240
|
+
tool: "delegate_task",
|
|
241
|
+
state: {
|
|
242
|
+
status: "completed",
|
|
243
|
+
input: {
|
|
244
|
+
run_in_background: true,
|
|
245
|
+
description: "model task",
|
|
246
|
+
subagent_type: "explore",
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
"utf8"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
const backgroundMessageDir = path.join(storage.message, backgroundSessionId)
|
|
254
|
+
fs.mkdirSync(backgroundMessageDir, { recursive: true })
|
|
255
|
+
fs.writeFileSync(
|
|
256
|
+
path.join(backgroundMessageDir, "msg_bg_1.json"),
|
|
257
|
+
JSON.stringify({
|
|
258
|
+
id: "msg_bg_1",
|
|
259
|
+
sessionID: backgroundSessionId,
|
|
260
|
+
role: "assistant",
|
|
261
|
+
providerID: "openai",
|
|
262
|
+
modelID: "gpt-4o",
|
|
263
|
+
time: { created: 1100 },
|
|
264
|
+
}),
|
|
265
|
+
"utf8"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
const payload = buildDashboardPayload({
|
|
269
|
+
projectRoot,
|
|
270
|
+
storage,
|
|
271
|
+
nowMs: 2000,
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
expect(payload.mainSession.currentModel).toBeNull()
|
|
275
|
+
expect(payload.backgroundTasks).toHaveLength(1)
|
|
276
|
+
expect(payload.backgroundTasks[0]?.lastModel).toBe("openai/gpt-4o")
|
|
277
|
+
expect(payload.raw).toHaveProperty("backgroundTasks.0.lastModel", "openai/gpt-4o")
|
|
278
|
+
} finally {
|
|
279
|
+
fs.rmSync(storageRoot, { recursive: true, force: true })
|
|
280
|
+
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it("does not include elapsed time in status pill when status is unknown", () => {
|
|
285
|
+
const storageRoot = mkStorageRoot()
|
|
286
|
+
const storage = getStorageRoots(storageRoot)
|
|
287
|
+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
|
|
288
|
+
const sessionId = "ses_unknown"
|
|
289
|
+
const projectID = "proj_1"
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const sessionMetaDir = path.join(storage.session, projectID)
|
|
293
|
+
fs.mkdirSync(sessionMetaDir, { recursive: true })
|
|
294
|
+
fs.writeFileSync(
|
|
295
|
+
path.join(sessionMetaDir, `${sessionId}.json`),
|
|
296
|
+
JSON.stringify({
|
|
297
|
+
id: sessionId,
|
|
298
|
+
projectID,
|
|
299
|
+
directory: projectRoot,
|
|
300
|
+
time: { created: 1000, updated: 1000 },
|
|
301
|
+
}),
|
|
302
|
+
"utf8"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
const payload = buildDashboardPayload({
|
|
306
|
+
projectRoot,
|
|
307
|
+
storage,
|
|
308
|
+
nowMs: 65_000,
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
expect(payload.mainSession.statusPill).toBe("unknown")
|
|
312
|
+
} finally {
|
|
313
|
+
fs.rmSync(storageRoot, { recursive: true, force: true })
|
|
314
|
+
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
315
|
+
}
|
|
316
|
+
})
|
|
178
317
|
})
|
package/src/server/dashboard.ts
CHANGED
|
@@ -3,9 +3,40 @@ import * as path from "node:path"
|
|
|
3
3
|
import { readBoulderState, readPlanProgress, readPlanSteps, type PlanStep } from "../ingest/boulder"
|
|
4
4
|
import { deriveBackgroundTasks } from "../ingest/background-tasks"
|
|
5
5
|
import { deriveTimeSeriesActivity, type TimeSeriesPayload } from "../ingest/timeseries"
|
|
6
|
-
import { getMainSessionView, getStorageRoots, pickActiveSessionId, readMainSessionMetas, type OpenCodeStorageRoots, type SessionMetadata } from "../ingest/session"
|
|
6
|
+
import { getMainSessionView, getStorageRoots, pickActiveSessionId, readMainSessionMetas, type MainSessionView, type OpenCodeStorageRoots, type SessionMetadata } from "../ingest/session"
|
|
7
7
|
|
|
8
8
|
export type DashboardPayload = {
|
|
9
|
+
mainSession: {
|
|
10
|
+
agent: string
|
|
11
|
+
currentModel: string | null
|
|
12
|
+
currentTool: string
|
|
13
|
+
lastUpdatedLabel: string
|
|
14
|
+
session: string
|
|
15
|
+
statusPill: string
|
|
16
|
+
}
|
|
17
|
+
planProgress: {
|
|
18
|
+
name: string
|
|
19
|
+
completed: number
|
|
20
|
+
total: number
|
|
21
|
+
path: string
|
|
22
|
+
statusPill: string
|
|
23
|
+
steps: PlanStep[]
|
|
24
|
+
}
|
|
25
|
+
backgroundTasks: Array<{
|
|
26
|
+
id: string
|
|
27
|
+
description: string
|
|
28
|
+
agent: string
|
|
29
|
+
lastModel: string | null
|
|
30
|
+
status: string
|
|
31
|
+
toolCalls: number
|
|
32
|
+
lastTool: string
|
|
33
|
+
timeline: string
|
|
34
|
+
}>
|
|
35
|
+
timeSeries: TimeSeriesPayload
|
|
36
|
+
raw: unknown
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type LegacyDashboardPayload = {
|
|
9
40
|
mainSession: {
|
|
10
41
|
agent: string
|
|
11
42
|
currentTool: string
|
|
@@ -35,7 +66,7 @@ export type DashboardPayload = {
|
|
|
35
66
|
}
|
|
36
67
|
|
|
37
68
|
export type DashboardStore = {
|
|
38
|
-
getSnapshot: () => DashboardPayload
|
|
69
|
+
getSnapshot: () => DashboardPayload | LegacyDashboardPayload
|
|
39
70
|
}
|
|
40
71
|
|
|
41
72
|
function formatIso(ts: number | null): string {
|
|
@@ -101,10 +132,13 @@ export function buildDashboardPayload(opts: {
|
|
|
101
132
|
mainSessionId: sessionId ?? null,
|
|
102
133
|
nowMs,
|
|
103
134
|
})
|
|
104
|
-
|
|
135
|
+
const mainCurrentModel = "currentModel" in main
|
|
136
|
+
? (main as MainSessionView).currentModel
|
|
137
|
+
: null
|
|
105
138
|
const payload: DashboardPayload = {
|
|
106
139
|
mainSession: {
|
|
107
140
|
agent: main.agent,
|
|
141
|
+
currentModel: mainCurrentModel,
|
|
108
142
|
currentTool: main.currentTool ?? "-",
|
|
109
143
|
lastUpdatedLabel: formatIso(main.lastUpdated),
|
|
110
144
|
session: main.sessionLabel,
|
|
@@ -122,6 +156,7 @@ export function buildDashboardPayload(opts: {
|
|
|
122
156
|
id: t.id,
|
|
123
157
|
description: t.description,
|
|
124
158
|
agent: t.agent,
|
|
159
|
+
lastModel: t.lastModel ?? null,
|
|
125
160
|
status: t.status,
|
|
126
161
|
toolCalls: t.toolCalls ?? 0,
|
|
127
162
|
lastTool: t.lastTool ?? "-",
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { computeStackedSegments, AgentCounts, StackedSegment } from "./timeseries-stacked";
|
|
3
|
+
|
|
4
|
+
describe("computeStackedSegments", () => {
|
|
5
|
+
describe("Edge cases", () => {
|
|
6
|
+
it("should return empty array when chartHeight <= 0", () => {
|
|
7
|
+
const counts: AgentCounts = { sisyphus: 10, prometheus: 5, atlas: 3 };
|
|
8
|
+
|
|
9
|
+
expect(computeStackedSegments(counts, 20, 0)).toEqual([]);
|
|
10
|
+
expect(computeStackedSegments(counts, 20, -5)).toEqual([]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("should return empty array when scaleMax <= 0", () => {
|
|
14
|
+
const counts: AgentCounts = { sisyphus: 10, prometheus: 5, atlas: 3 };
|
|
15
|
+
|
|
16
|
+
expect(computeStackedSegments(counts, 0, 100)).toEqual([]);
|
|
17
|
+
expect(computeStackedSegments(counts, -10, 100)).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should return empty array when all counts are zero", () => {
|
|
21
|
+
const counts: AgentCounts = { sisyphus: 0, prometheus: 0, atlas: 0 };
|
|
22
|
+
|
|
23
|
+
const result = computeStackedSegments(counts, 20, 100);
|
|
24
|
+
expect(result).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should handle invalid/missing counts gracefully", () => {
|
|
28
|
+
const invalidCounts = {
|
|
29
|
+
sisyphus: NaN,
|
|
30
|
+
prometheus: Infinity,
|
|
31
|
+
atlas: -5,
|
|
32
|
+
} as unknown as AgentCounts;
|
|
33
|
+
|
|
34
|
+
const result = computeStackedSegments(invalidCounts, 20, 100);
|
|
35
|
+
expect(result).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should handle mixed valid/invalid counts", () => {
|
|
39
|
+
const mixedCounts = {
|
|
40
|
+
sisyphus: 10,
|
|
41
|
+
prometheus: NaN,
|
|
42
|
+
atlas: -3,
|
|
43
|
+
} as unknown as AgentCounts;
|
|
44
|
+
|
|
45
|
+
const result = computeStackedSegments(mixedCounts, 20, 100);
|
|
46
|
+
expect(result).toHaveLength(1);
|
|
47
|
+
expect(result[0]).toEqual({
|
|
48
|
+
tone: "teal",
|
|
49
|
+
y: 50,
|
|
50
|
+
height: 50,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("Single agent scenarios", () => {
|
|
56
|
+
it("should return one segment when only sisyphus is non-zero", () => {
|
|
57
|
+
const counts: AgentCounts = { sisyphus: 10, prometheus: 0, atlas: 0 };
|
|
58
|
+
|
|
59
|
+
const result = computeStackedSegments(counts, 20, 100);
|
|
60
|
+
expect(result).toHaveLength(1);
|
|
61
|
+
expect(result[0]).toEqual({
|
|
62
|
+
tone: "teal",
|
|
63
|
+
y: 50,
|
|
64
|
+
height: 50,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should return one segment when only prometheus is non-zero", () => {
|
|
69
|
+
const counts: AgentCounts = { sisyphus: 0, prometheus: 15, atlas: 0 };
|
|
70
|
+
|
|
71
|
+
const result = computeStackedSegments(counts, 30, 120);
|
|
72
|
+
expect(result).toHaveLength(1);
|
|
73
|
+
expect(result[0]).toEqual({
|
|
74
|
+
tone: "red",
|
|
75
|
+
y: 60,
|
|
76
|
+
height: 60,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should return one segment when only atlas is non-zero", () => {
|
|
81
|
+
const counts: AgentCounts = { sisyphus: 0, prometheus: 0, atlas: 8 };
|
|
82
|
+
|
|
83
|
+
const result = computeStackedSegments(counts, 16, 80);
|
|
84
|
+
expect(result).toHaveLength(1);
|
|
85
|
+
expect(result[0]).toEqual({
|
|
86
|
+
tone: "green",
|
|
87
|
+
y: 40,
|
|
88
|
+
height: 40,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should round to at least 1px for non-zero values", () => {
|
|
93
|
+
const counts: AgentCounts = { sisyphus: 1, prometheus: 0, atlas: 0 };
|
|
94
|
+
|
|
95
|
+
const result = computeStackedSegments(counts, 1000, 100);
|
|
96
|
+
expect(result).toHaveLength(1);
|
|
97
|
+
expect(result[0].height).toBeGreaterThanOrEqual(1);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("Multiple agent scenarios", () => {
|
|
102
|
+
it("should return multiple segments in correct order (bottom to top)", () => {
|
|
103
|
+
const counts: AgentCounts = { sisyphus: 10, prometheus: 20, atlas: 15 };
|
|
104
|
+
|
|
105
|
+
const result = computeStackedSegments(counts, 50, 100);
|
|
106
|
+
expect(result).toHaveLength(3);
|
|
107
|
+
|
|
108
|
+
// Check order: teal (sisyphus) -> red (prometheus) -> green (atlas)
|
|
109
|
+
expect(result[0].tone).toBe("teal");
|
|
110
|
+
expect(result[1].tone).toBe("red");
|
|
111
|
+
expect(result[2].tone).toBe("green");
|
|
112
|
+
|
|
113
|
+
// Check positions (y increases upward, so atlas should have smallest y)
|
|
114
|
+
expect(result[0].y).toBeGreaterThan(result[1].y);
|
|
115
|
+
expect(result[1].y).toBeGreaterThan(result[2].y);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("should correctly calculate heights for all agents", () => {
|
|
119
|
+
const counts: AgentCounts = { sisyphus: 10, prometheus: 20, atlas: 15 };
|
|
120
|
+
|
|
121
|
+
const result = computeStackedSegments(counts, 50, 100);
|
|
122
|
+
const totalHeight = result.reduce((sum, seg) => sum + seg.height, 0);
|
|
123
|
+
|
|
124
|
+
expect(totalHeight).toBeLessThanOrEqual(100);
|
|
125
|
+
|
|
126
|
+
// Expected heights: 20, 40, 30
|
|
127
|
+
expect(result[0].height).toBe(20); // sisyphus
|
|
128
|
+
expect(result[1].height).toBe(40); // prometheus
|
|
129
|
+
expect(result[2].height).toBe(30); // atlas
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should handle zero values mixed with non-zero values", () => {
|
|
133
|
+
const counts: AgentCounts = { sisyphus: 10, prometheus: 0, atlas: 15 };
|
|
134
|
+
|
|
135
|
+
const result = computeStackedSegments(counts, 30, 90);
|
|
136
|
+
expect(result).toHaveLength(2);
|
|
137
|
+
|
|
138
|
+
// Should only have teal (sisyphus) and green (atlas)
|
|
139
|
+
expect(result[0].tone).toBe("teal");
|
|
140
|
+
expect(result[1].tone).toBe("green");
|
|
141
|
+
|
|
142
|
+
// Check positioning
|
|
143
|
+
expect(result[0].y).toBeGreaterThan(result[1].y);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("Clamping and overflow behavior", () => {
|
|
148
|
+
it("should ensure sum of heights never exceeds chartHeight", () => {
|
|
149
|
+
const counts: AgentCounts = { sisyphus: 100, prometheus: 100, atlas: 100 };
|
|
150
|
+
|
|
151
|
+
const result = computeStackedSegments(counts, 100, 50); // Should overflow
|
|
152
|
+
const totalHeight = result.reduce((sum, seg) => sum + seg.height, 0);
|
|
153
|
+
|
|
154
|
+
expect(totalHeight).toBeLessThanOrEqual(50);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("should preserve at least 1px for non-zero agents when possible", () => {
|
|
158
|
+
const counts: AgentCounts = { sisyphus: 1, prometheus: 1, atlas: 1 };
|
|
159
|
+
|
|
160
|
+
const result = computeStackedSegments(counts, 100, 10);
|
|
161
|
+
|
|
162
|
+
// All agents should be visible with at least 1px each
|
|
163
|
+
expect(result).toHaveLength(3);
|
|
164
|
+
expect(result.every(seg => seg.height >= 1)).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("should distribute overflow reduction fairly", () => {
|
|
168
|
+
const counts: AgentCounts = { sisyphus: 40, prometheus: 35, atlas: 25 };
|
|
169
|
+
|
|
170
|
+
const result = computeStackedSegments(counts, 100, 80);
|
|
171
|
+
const totalHeight = result.reduce((sum, seg) => sum + seg.height, 0);
|
|
172
|
+
|
|
173
|
+
expect(totalHeight).toBeLessThanOrEqual(80);
|
|
174
|
+
|
|
175
|
+
// Larger segments should be reduced more
|
|
176
|
+
const heights = result.map(seg => seg.height);
|
|
177
|
+
expect(Math.max(...heights)).toBeLessThanOrEqual(40); // Original largest was 40
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should handle extreme overflow gracefully", () => {
|
|
181
|
+
const counts: AgentCounts = { sisyphus: 1000, prometheus: 1000, atlas: 1000 };
|
|
182
|
+
|
|
183
|
+
const result = computeStackedSegments(counts, 100, 5);
|
|
184
|
+
const totalHeight = result.reduce((sum, seg) => sum + seg.height, 0);
|
|
185
|
+
|
|
186
|
+
expect(totalHeight).toBeLessThanOrEqual(5);
|
|
187
|
+
// Should still try to show all agents
|
|
188
|
+
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("Deterministic behavior", () => {
|
|
193
|
+
it("should produce identical results for identical inputs", () => {
|
|
194
|
+
const counts: AgentCounts = { sisyphus: 15, prometheus: 25, atlas: 10 };
|
|
195
|
+
|
|
196
|
+
const result1 = computeStackedSegments(counts, 60, 100);
|
|
197
|
+
const result2 = computeStackedSegments(counts, 60, 100);
|
|
198
|
+
|
|
199
|
+
expect(result1).toEqual(result2);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("should maintain consistent segment order regardless of input magnitudes", () => {
|
|
203
|
+
const testCases = [
|
|
204
|
+
{ sisyphus: 100, prometheus: 1, atlas: 1 },
|
|
205
|
+
{ sisyphus: 1, prometheus: 100, atlas: 1 },
|
|
206
|
+
{ sisyphus: 1, prometheus: 1, atlas: 100 },
|
|
207
|
+
{ sisyphus: 50, prometheus: 25, atlas: 75 },
|
|
208
|
+
] as AgentCounts[];
|
|
209
|
+
|
|
210
|
+
testCases.forEach(counts => {
|
|
211
|
+
const result = computeStackedSegments(counts, 100, 100);
|
|
212
|
+
const tones = result.map(seg => seg.tone);
|
|
213
|
+
|
|
214
|
+
if (result.length === 3) {
|
|
215
|
+
expect(tones).toEqual(["teal", "red", "green"]);
|
|
216
|
+
} else if (result.length === 2) {
|
|
217
|
+
// Should still be in correct order, just missing zero-valued agents
|
|
218
|
+
expect(tones).toEqual(expect.arrayContaining([
|
|
219
|
+
expect.stringMatching(/teal|red|green/)
|
|
220
|
+
]));
|
|
221
|
+
// Check that order is preserved for present agents
|
|
222
|
+
const toneIndex = (t: string) => ["teal", "red", "green"].indexOf(t);
|
|
223
|
+
for (let i = 1; i < tones.length; i++) {
|
|
224
|
+
expect(toneIndex(tones[i])).toBeGreaterThan(toneIndex(tones[i-1]));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("Boundary conditions", () => {
|
|
232
|
+
it("should handle very small chartHeight", () => {
|
|
233
|
+
const counts: AgentCounts = { sisyphus: 10, prometheus: 5, atlas: 3 };
|
|
234
|
+
|
|
235
|
+
const result = computeStackedSegments(counts, 20, 1);
|
|
236
|
+
const totalHeight = result.reduce((sum, seg) => sum + seg.height, 0);
|
|
237
|
+
|
|
238
|
+
expect(totalHeight).toBeLessThanOrEqual(1);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("should handle very large scaleMax", () => {
|
|
242
|
+
const counts: AgentCounts = { sisyphus: 10, prometheus: 5, atlas: 3 };
|
|
243
|
+
|
|
244
|
+
const result = computeStackedSegments(counts, 1000000, 100);
|
|
245
|
+
|
|
246
|
+
// Should produce very small but non-zero heights
|
|
247
|
+
expect(result.length).toBeGreaterThan(0);
|
|
248
|
+
expect(result.every(seg => seg.height >= 1)).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("should handle fractional results correctly", () => {
|
|
252
|
+
const counts: AgentCounts = { sisyphus: 1, prometheus: 1, atlas: 1 };
|
|
253
|
+
|
|
254
|
+
const result = computeStackedSegments(counts, 3, 10);
|
|
255
|
+
|
|
256
|
+
// All heights should be integers
|
|
257
|
+
expect(result.every(seg => Number.isInteger(seg.height))).toBe(true);
|
|
258
|
+
expect(result.every(seg => Number.isInteger(seg.y))).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|