| ... | ... | @@ -26,6 +26,7 @@ import { createEnvironmentSnapshotAtom } from "./snapshots.ts"; |
| 26 | 26 | import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts"; |
| 27 | 27 | import { mergeEnvironmentThread } from "./threadDetail.ts"; |
| 28 | 28 | import { createEnvironmentThreadShellAtoms } from "./threadShell.ts"; |
| 29 | import { applyShellStreamEvent } from "./shellReducer.ts"; |
| 29 | 30 | |
| 30 | 31 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); |
| 31 | 32 | const PROJECT_ID = ProjectId.make("project-1"); |
| ... | ... | @@ -149,7 +150,7 @@ function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState |
| 149 | 150 | }; |
| 150 | 151 | } |
| 151 | 152 | |
| 152 | | function makeHarness() { |
| 153 | function makeHarness(environmentIds: ReadonlyArray<EnvironmentId> = [ENVIRONMENT_ID]) { |
| 153 | 154 | const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) => |
| 154 | 155 | Atom.make(AsyncResult.success(shellState(SNAPSHOT))), |
| 155 | 156 | ); |
| ... | ... | @@ -158,20 +159,20 @@ function makeHarness() { |
| 158 | 159 | ); |
| 159 | 160 | const catalogValueAtom = Atom.make({ |
| 160 | 161 | isReady: true, |
| 161 | | entries: new Map([ |
| 162 | | [ |
| 163 | | ENVIRONMENT_ID, |
| 162 | entries: new Map( |
| 163 | environmentIds.map((environmentId) => [ |
| 164 | environmentId, |
| 164 | 165 | { |
| 165 | 166 | target: new PrimaryConnectionTarget({ |
| 166 | | environmentId: ENVIRONMENT_ID, |
| 167 | environmentId, |
| 167 | 168 | label: "Environment", |
| 168 | 169 | httpBaseUrl: "https://example.test", |
| 169 | 170 | wsBaseUrl: "wss://example.test", |
| 170 | 171 | }), |
| 171 | 172 | profile: Option.none(), |
| 172 | 173 | }, |
| 173 | | ], |
| 174 | | ]), |
| 174 | ]), |
| 175 | ), |
| 175 | 176 | }); |
| 176 | 177 | const snapshotAtom = createEnvironmentSnapshotAtom(shellStateAtoms); |
| 177 | 178 | const projects = createEnvironmentProjectAtoms({ |
| ... | ... | @@ -188,7 +189,9 @@ function makeHarness() { |
| 188 | 189 | |
| 189 | 190 | return { |
| 190 | 191 | registry: AtomRegistry.make(), |
| 192 | catalogValueAtom, |
| 191 | 193 | shellStateAtom: shellStateAtoms(ENVIRONMENT_ID), |
| 194 | shellStateAtomForEnvironment: shellStateAtoms, |
| 192 | 195 | threadStateAtom: (threadId: ThreadId) => threadStateAtoms(`${ENVIRONMENT_ID}\u0000${threadId}`), |
| 193 | 196 | projects, |
| 194 | 197 | threadShells, |
| ... | ... | @@ -279,7 +282,8 @@ describe("environment entity projections", () => { |
| 279 | 282 | const refsByProjectAtom = |
| 280 | 283 | harness.threadShells.environmentThreadRefsByProjectAtom(ENVIRONMENT_ID); |
| 281 | 284 | const threadsAtom = harness.threadShells.threadShellsForProjectRefsAtom([projectRef]); |
| 282 | | const refs = harness.registry.get(refsByProjectAtom).get(PROJECT_ID); |
| 285 | const membership = harness.registry.get(refsByProjectAtom); |
| 286 | const refs = membership.get(PROJECT_ID); |
| 283 | 287 | const threads = harness.registry.get(threadsAtom); |
| 284 | 288 | |
| 285 | 289 | expect(threads).toHaveLength(1); |
| ... | ... | @@ -299,9 +303,158 @@ describe("environment entity projections", () => { |
| 299 | 303 | ); |
| 300 | 304 | |
| 301 | 305 | expect(harness.registry.get(refsByProjectAtom).get(PROJECT_ID)).toBe(refs); |
| 306 | expect(harness.registry.get(refsByProjectAtom)).toBe(membership); |
| 302 | 307 | expect(harness.registry.get(threadsAtom)).toBe(threads); |
| 303 | 308 | }); |
| 304 | 309 | |
| 310 | it("shares list values with point reads without retaining one atom per listed thread", () => { |
| 311 | const harness = makeHarness(); |
| 312 | let snapshot: OrchestrationShellSnapshot = { |
| 313 | ...SNAPSHOT, |
| 314 | threads: Array.from({ length: 200 }, (_, index) => ({ |
| 315 | ...THREAD_SHELL, |
| 316 | id: ThreadId.make(`listed-${index}`), |
| 317 | })), |
| 318 | }; |
| 319 | harness.registry.set(harness.shellStateAtom, AsyncResult.success(shellState(snapshot))); |
| 320 | const listAtom = harness.threadShells.threadShellsAtom; |
| 321 | const projectListAtom = harness.threadShells.threadShellsForProjectRefsAtom([ |
| 322 | { environmentId: ENVIRONMENT_ID, projectId: PROJECT_ID }, |
| 323 | ]); |
| 324 | const disposeList = harness.registry.mount(listAtom); |
| 325 | const disposeProjectList = harness.registry.mount(projectListAtom); |
| 326 | try { |
| 327 | const before = harness.registry.get(listAtom); |
| 328 | expect(before).toHaveLength(200); |
| 329 | expect(harness.registry.get(projectListAtom)).toEqual(before); |
| 330 | expect(harness.registry.getNodes().size).toBeLessThan(20); |
| 331 | const firstAtom = harness.threadShells.threadShellAtom({ |
| 332 | environmentId: ENVIRONMENT_ID, |
| 333 | threadId: snapshot.threads[0]!.id, |
| 334 | }); |
| 335 | expect(harness.registry.get(firstAtom)).toBe(before[0]); |
| 336 | |
| 337 | snapshot = applyShellStreamEvent(snapshot, { |
| 338 | kind: "thread-upserted", |
| 339 | sequence: 2, |
| 340 | thread: { ...snapshot.threads.at(-1)!, title: "Updated last thread" }, |
| 341 | }); |
| 342 | harness.registry.set(harness.shellStateAtom, AsyncResult.success(shellState(snapshot))); |
| 343 | const after = harness.registry.get(listAtom); |
| 344 | expect(after[0]).toBe(before[0]); |
| 345 | expect(after.at(-1)).not.toBe(before.at(-1)); |
| 346 | expect(after.at(-1)?.title).toBe("Updated last thread"); |
| 347 | expect(harness.registry.get(projectListAtom).at(-1)).toBe(after.at(-1)); |
| 348 | expect(harness.registry.get(firstAtom)).toBe(after[0]); |
| 349 | expect(harness.registry.getNodes().size).toBeLessThan(20); |
| 350 | } finally { |
| 351 | disposeProjectList(); |
| 352 | disposeList(); |
| 353 | harness.registry.dispose(); |
| 354 | } |
| 355 | }); |
| 356 | |
| 357 | it("keeps scoped identities and list order across project and environment changes", () => { |
| 358 | const remoteEnvironmentId = EnvironmentId.make("remote-environment"); |
| 359 | const harness = makeHarness([ENVIRONMENT_ID, remoteEnvironmentId]); |
| 360 | const listAtom = harness.threadShells.threadShellsAtom; |
| 361 | const localRef = { environmentId: ENVIRONMENT_ID, threadId: THREAD_ID }; |
| 362 | const remoteRef = { environmentId: remoteEnvironmentId, threadId: THREAD_ID }; |
| 363 | const selectedAtom = harness.threadShells.threadShellsForProjectRefsAtom([ |
| 364 | { environmentId: remoteEnvironmentId, projectId: PROJECT_ID }, |
| 365 | { environmentId: ENVIRONMENT_ID, projectId: OTHER_PROJECT_ID }, |
| 366 | { environmentId: remoteEnvironmentId, projectId: PROJECT_ID }, |
| 367 | { environmentId: ENVIRONMENT_ID, projectId: PROJECT_ID }, |
| 368 | ]); |
| 369 | const disposeList = harness.registry.mount(listAtom); |
| 370 | const disposeSelected = harness.registry.mount(selectedAtom); |
| 371 | try { |
| 372 | const original = harness.registry.get(listAtom); |
| 373 | const local = harness.registry.get(harness.threadShells.threadShellAtom(localRef)); |
| 374 | const remote = harness.registry.get(harness.threadShells.threadShellAtom(remoteRef)); |
| 375 | expect(original).toHaveLength(4); |
| 376 | expect(original[0]).toBe(local); |
| 377 | expect(original[2]).toBe(remote); |
| 378 | expect(local).not.toBe(remote); |
| 379 | expect(local?.environmentId).toBe(ENVIRONMENT_ID); |
| 380 | expect(remote?.environmentId).toBe(remoteEnvironmentId); |
| 381 | expect(harness.registry.get(selectedAtom)).toEqual([remote, original[1], local]); |
| 382 | |
| 383 | harness.registry.set( |
| 384 | harness.shellStateAtomForEnvironment(remoteEnvironmentId), |
| 385 | AsyncResult.success(shellState({ ...SNAPSHOT, threads: SNAPSHOT.threads.toReversed() })), |
| 386 | ); |
| 387 | expect([ |
| 388 | ...harness.registry |
| 389 | .get(harness.threadShells.environmentThreadRefsByProjectAtom(remoteEnvironmentId)) |
| 390 | .keys(), |
| 391 | ]).toEqual([OTHER_PROJECT_ID, PROJECT_ID]); |
| 392 | harness.registry.set( |
| 393 | harness.shellStateAtomForEnvironment(remoteEnvironmentId), |
| 394 | AsyncResult.success(shellState(SNAPSHOT)), |
| 395 | ); |
| 396 | |
| 397 | let snapshot = applyShellStreamEvent(SNAPSHOT, { |
| 398 | kind: "thread-upserted", |
| 399 | sequence: 2, |
| 400 | thread: { ...THREAD_SHELL, projectId: OTHER_PROJECT_ID, title: "Moved thread" }, |
| 401 | }); |
| 402 | harness.registry.set(harness.shellStateAtom, AsyncResult.success(shellState(snapshot))); |
| 403 | const moved = harness.registry.get(harness.threadShells.threadShellAtom(localRef)); |
| 404 | expect(harness.registry.get(selectedAtom)).toEqual([remote, moved, original[1]]); |
| 405 | expect(harness.registry.get(listAtom)[2]).toBe(remote); |
| 406 | |
| 407 | snapshot = applyShellStreamEvent(snapshot, { |
| 408 | kind: "thread-removed", |
| 409 | sequence: 3, |
| 410 | threadId: OTHER_THREAD_ID, |
| 411 | }); |
| 412 | harness.registry.set(harness.shellStateAtom, AsyncResult.success(shellState(snapshot))); |
| 413 | expect(harness.registry.get(selectedAtom)).toEqual([remote, moved]); |
| 414 | expect( |
| 415 | harness.registry.get( |
| 416 | harness.threadShells.threadShellAtom({ |
| 417 | environmentId: ENVIRONMENT_ID, |
| 418 | threadId: OTHER_THREAD_ID, |
| 419 | }), |
| 420 | ), |
| 421 | ).toBeNull(); |
| 422 | |
| 423 | const createdId = ThreadId.make("created-thread"); |
| 424 | snapshot = applyShellStreamEvent(snapshot, { |
| 425 | kind: "thread-upserted", |
| 426 | sequence: 4, |
| 427 | thread: { ...THREAD_SHELL, id: createdId }, |
| 428 | }); |
| 429 | harness.registry.set(harness.shellStateAtom, AsyncResult.success(shellState(snapshot))); |
| 430 | const created = harness.registry.get(listAtom)[1]; |
| 431 | expect(created?.id).toBe(createdId); |
| 432 | expect(harness.registry.get(selectedAtom)).toEqual([remote, moved, created]); |
| 433 | |
| 434 | const catalog = harness.registry.get(harness.catalogValueAtom); |
| 435 | harness.registry.set(harness.catalogValueAtom, { |
| 436 | ...catalog, |
| 437 | entries: new Map([...catalog.entries].toReversed()), |
| 438 | }); |
| 439 | expect(harness.registry.get(listAtom)).toEqual([remote, original[3], moved, created]); |
| 440 | harness.registry.set(harness.catalogValueAtom, { |
| 441 | ...catalog, |
| 442 | entries: new Map([[remoteEnvironmentId, catalog.entries.get(remoteEnvironmentId)!]]), |
| 443 | }); |
| 444 | expect(harness.registry.get(listAtom)).toEqual([remote, original[3]]); |
| 445 | harness.registry.set( |
| 446 | harness.shellStateAtomForEnvironment(remoteEnvironmentId), |
| 447 | AsyncResult.success(shellState({ ...SNAPSHOT, threads: [] })), |
| 448 | ); |
| 449 | expect(harness.registry.get(listAtom)).toEqual([]); |
| 450 | expect(harness.registry.get(harness.threadShells.threadShellAtom(remoteRef))).toBeNull(); |
| 451 | } finally { |
| 452 | disposeSelected(); |
| 453 | disposeList(); |
| 454 | harness.registry.dispose(); |
| 455 | } |
| 456 | }); |
| 457 | |
| 305 | 458 | it("updates only the requested thread detail and preserves untouched field identities", () => { |
| 306 | 459 | const harness = makeHarness(); |
| 307 | 460 | const threadRef = { |