| ... | @@ -26,6 +26,7 @@ import { createEnvironmentSnapshotAtom } from "./snapshots.ts"; | ... | @@ -26,6 +26,7 @@ import { createEnvironmentSnapshotAtom } from "./snapshots.ts"; |
| 26 | import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts"; | 26 | import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts"; |
| 27 | import { mergeEnvironmentThread } from "./threadDetail.ts"; | 27 | import { mergeEnvironmentThread } from "./threadDetail.ts"; |
| 28 | import { createEnvironmentThreadShellAtoms } from "./threadShell.ts"; | 28 | import { createEnvironmentThreadShellAtoms } from "./threadShell.ts"; |
| | 29 | import { applyShellStreamEvent } from "./shellReducer.ts"; |
| 29 | | 30 | |
| 30 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); | 31 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); |
| 31 | const PROJECT_ID = ProjectId.make("project-1"); | 32 | const PROJECT_ID = ProjectId.make("project-1"); |
| ... | @@ -149,7 +150,7 @@ function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState | ... | @@ -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 | const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) => | 154 | const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) => |
| 154 | Atom.make(AsyncResult.success(shellState(SNAPSHOT))), | 155 | Atom.make(AsyncResult.success(shellState(SNAPSHOT))), |
| 155 | ); | 156 | ); |
| ... | @@ -158,20 +159,20 @@ function makeHarness() { | ... | @@ -158,20 +159,20 @@ function makeHarness() { |
| 158 | ); | 159 | ); |
| 159 | const catalogValueAtom = Atom.make({ | 160 | const catalogValueAtom = Atom.make({ |
| 160 | isReady: true, | 161 | isReady: true, |
| 161 | entries: new Map([ | 162 | entries: new Map( |
| 162 | [ | 163 | environmentIds.map((environmentId) => [ |
| 163 | ENVIRONMENT_ID, | 164 | environmentId, |
| 164 | { | 165 | { |
| 165 | target: new PrimaryConnectionTarget({ | 166 | target: new PrimaryConnectionTarget({ |
| 166 | environmentId: ENVIRONMENT_ID, | 167 | environmentId, |
| 167 | label: "Environment", | 168 | label: "Environment", |
| 168 | httpBaseUrl: "https://example.test", | 169 | httpBaseUrl: "https://example.test", |
| 169 | wsBaseUrl: "wss://example.test", | 170 | wsBaseUrl: "wss://example.test", |
| 170 | }), | 171 | }), |
| 171 | profile: Option.none(), | 172 | profile: Option.none(), |
| 172 | }, | 173 | }, |
| 173 | ], | 174 | ]), |
| 174 | ]), | 175 | ), |
| 175 | }); | 176 | }); |
| 176 | const snapshotAtom = createEnvironmentSnapshotAtom(shellStateAtoms); | 177 | const snapshotAtom = createEnvironmentSnapshotAtom(shellStateAtoms); |
| 177 | const projects = createEnvironmentProjectAtoms({ | 178 | const projects = createEnvironmentProjectAtoms({ |
| ... | @@ -188,7 +189,9 @@ function makeHarness() { | ... | @@ -188,7 +189,9 @@ function makeHarness() { |
| 188 | | 189 | |
| 189 | return { | 190 | return { |
| 190 | registry: AtomRegistry.make(), | 191 | registry: AtomRegistry.make(), |
| | 192 | catalogValueAtom, |
| 191 | shellStateAtom: shellStateAtoms(ENVIRONMENT_ID), | 193 | shellStateAtom: shellStateAtoms(ENVIRONMENT_ID), |
| | 194 | shellStateAtomForEnvironment: shellStateAtoms, |
| 192 | threadStateAtom: (threadId: ThreadId) => threadStateAtoms(`${ENVIRONMENT_ID}\u0000${threadId}`), | 195 | threadStateAtom: (threadId: ThreadId) => threadStateAtoms(`${ENVIRONMENT_ID}\u0000${threadId}`), |
| 193 | projects, | 196 | projects, |
| 194 | threadShells, | 197 | threadShells, |
| ... | @@ -279,7 +282,8 @@ describe("environment entity projections", () => { | ... | @@ -279,7 +282,8 @@ describe("environment entity projections", () => { |
| 279 | const refsByProjectAtom = | 282 | const refsByProjectAtom = |
| 280 | harness.threadShells.environmentThreadRefsByProjectAtom(ENVIRONMENT_ID); | 283 | harness.threadShells.environmentThreadRefsByProjectAtom(ENVIRONMENT_ID); |
| 281 | const threadsAtom = harness.threadShells.threadShellsForProjectRefsAtom([projectRef]); | 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 | const threads = harness.registry.get(threadsAtom); | 287 | const threads = harness.registry.get(threadsAtom); |
| 284 | | 288 | |
| 285 | expect(threads).toHaveLength(1); | 289 | expect(threads).toHaveLength(1); |
| ... | @@ -299,9 +303,158 @@ describe("environment entity projections", () => { | ... | @@ -299,9 +303,158 @@ describe("environment entity projections", () => { |
| 299 | ); | 303 | ); |
| 300 | | 304 | |
| 301 | expect(harness.registry.get(refsByProjectAtom).get(PROJECT_ID)).toBe(refs); | 305 | expect(harness.registry.get(refsByProjectAtom).get(PROJECT_ID)).toBe(refs); |
| | 306 | expect(harness.registry.get(refsByProjectAtom)).toBe(membership); |
| 302 | expect(harness.registry.get(threadsAtom)).toBe(threads); | 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 | it("updates only the requested thread detail and preserves untouched field identities", () => { | 458 | it("updates only the requested thread detail and preserves untouched field identities", () => { |
| 306 | const harness = makeHarness(); | 459 | const harness = makeHarness(); |
| 307 | const threadRef = { | 460 | const threadRef = { |