1import { act, useLayoutEffect } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4import {
5 EnvironmentId,
6 RunId,
7 ThreadId,
8 type OrchestrationV2SearchThreadInput,
9 type OrchestrationV2SearchThreadResult,
10} from "@t3tools/contracts";
11import { useThreadFind } from "./useThreadFind";
12import { orchestrationEnvironment } from "~/state/orchestration";
13import { requestThreadFindOpen } from "./threadFindActionBus";
14
15const queries = vi.hoisted(() => ({
16 results: new Map<string, OrchestrationV2SearchThreadResult>(),
17 pending: false,
18 refresh: vi.fn(),
19}));
20vi.mock("~/state/orchestration", () => ({
21 orchestrationEnvironment: {
22 threadFindProgressive: vi.fn(
23 (input: { environmentId: EnvironmentId; input: OrchestrationV2SearchThreadInput }) => input,
24 ),
25 threadFind: vi.fn(
26 (input: { environmentId: EnvironmentId; input: OrchestrationV2SearchThreadInput }) => input,
27 ),
28 },
29}));
30vi.mock("~/state/queries", () => ({ useDebouncedValue: <T,>(value: T) => value }));
31vi.mock("~/state/query", () => ({
32 useEnvironmentQuery: (atom: { environmentId: EnvironmentId } | null) => ({
33 data: atom === null ? null : (queries.results.get(atom.environmentId) ?? null),
34 isPending: queries.pending,
35 error: null,
36 refresh: queries.refresh,
37 }),
38}));
39
40vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } }));
41
42let renderer: ReactTestRenderer | undefined;
43let find: ReturnType<typeof useThreadFind>;
44const a = EnvironmentId.make("environment:a");
45const b = EnvironmentId.make("environment:b");
46const threadId = ThreadId.make("shared-thread");
47const runId = RunId.make("run:plan");
48function Probe({
49 environmentId,
50 enabled = true,
51 progressive = false,
52 content,
53}: {
54 environmentId: EnvironmentId;
55 enabled?: boolean;
56 progressive?: boolean;
57 content?: Parameters<typeof useThreadFind>[0]["content"];
58}) {
59 const state = useThreadFind({
60 thread: { environmentId, threadId },
61 enabled,
62 progressive,
63 content,
64 });
65 useLayoutEffect(() => {
66 find = state;
67 });
68 return null;
69}
70beforeEach(() => {
71 vi.stubGlobal("window", new EventTarget());
72 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
73});
74afterEach(async () => {
75 if (renderer) await act(async () => renderer?.unmount());
76 renderer = undefined;
77 queries.results.clear();
78 queries.pending = false;
79 vi.clearAllMocks();
80 vi.useRealTimers();
81 vi.unstubAllGlobals();
82});
83
84describe("V2 find state", () => {
85 function messageResult(index: number, snapshotSequence = 9): OrchestrationV2SearchThreadResult {
86 return {
87 snapshotSequence,
88 totalMatches: 10,
89 activeIndex: index,
90 match: { entryId: `message:${index + 2}`, runId, occurrence: 0 },
91 };
92 }
93
94 it("reveals an early match and updates its count without requesting another jump", async () => {
95 await act(async () => {
96 renderer = create(<Probe environmentId={a} progressive />);
97 });
98 await act(async () => find.open());
99 await act(async () => find.barProps.onQueryChange("needle"));
100 const partial: OrchestrationV2SearchThreadResult = {
101 complete: false,
102 snapshotSequence: 9,
103 totalMatches: 1,
104 activeIndex: 0,
105 match: { entryId: "early", runId, occurrence: 2 },
106 };
107 queries.pending = true;
108 queries.results.set(a, partial);
109 await act(async () => renderer?.update(<Probe environmentId={a} progressive />));
110 expect(find.barProps.counting).toBe(true);
111 expect(find.barProps.status).toBeNull();
112 expect(find.timelineProps.activeFindMatch).toEqual(partial.match);
113 const match = find.timelineProps.activeFindMatch;
114 const navigationId = find.timelineProps.findNavigationId;
115 const requests = vi.mocked(orchestrationEnvironment.threadFindProgressive).mock.calls.length;
116 await act(async () => {
117 find.barProps.onNext();
118 find.barProps.onPrevious();
119 });
120 expect(orchestrationEnvironment.threadFindProgressive).toHaveBeenCalledTimes(requests);
121 queries.results.set(a, { ...partial, complete: true, totalMatches: 123, activeIndex: 42 });
122 await act(async () => renderer?.update(<Probe environmentId={a} progressive />));
123 expect(find.barProps.counting).toBe(false);
124 expect(find.barProps.matchCount).toBe(123);
125 expect(find.barProps.activeIndex).toBe(42);
126 expect(find.timelineProps.activeFindMatch).toBe(match);
127 expect(find.timelineProps.findNavigationId).toBe(navigationId);
128 expect(orchestrationEnvironment.threadFind).not.toHaveBeenCalled();
129 queries.pending = false;
130 await act(async () => renderer?.update(<Probe environmentId={a} progressive />));
131 await act(async () => find.barProps.onNext());
132 expect(orchestrationEnvironment.threadFindProgressive).toHaveBeenCalledTimes(requests + 1);
133 });
134
135 it("coalesces incoming pages until the progressive scan finishes", async () => {
136 vi.useFakeTimers();
137 queries.pending = true;
138 await act(async () => {
139 renderer = create(<Probe environmentId={a} progressive />);
140 });
141 await act(async () => find.open());
142 await act(async () => find.barProps.onQueryChange("needle"));
143 queries.results.set(a, { ...messageResult(0), complete: false, totalMatches: 1 });
144 const content = { visibleTurnItems: [], runs: [] };
145 await act(async () =>
146 renderer?.update(<Probe environmentId={a} progressive content={content} />),
147 );
148 const requests = vi.mocked(orchestrationEnvironment.threadFindProgressive).mock.calls.length;
149 await act(async () => vi.advanceTimersByTime(300));
150 expect(orchestrationEnvironment.threadFindProgressive).toHaveBeenCalledTimes(requests);
151 expect(queries.refresh).not.toHaveBeenCalled();
152 queries.results.set(a, messageResult(0));
153 queries.pending = false;
154 await act(async () =>
155 renderer?.update(<Probe environmentId={a} progressive content={content} />),
156 );
157 expect(orchestrationEnvironment.threadFindProgressive).toHaveBeenCalledTimes(requests + 1);
158 expect(orchestrationEnvironment.threadFindProgressive).toHaveBeenLastCalledWith({
159 environmentId: a,
160 input: {
161 threadId,
162 query: "needle",
163 skills: [],
164 start: { entryId: "message:2", occurrence: 0 },
165 },
166 });
167 expect(find.timelineProps.activeFindMatch?.entryId).toBe("message:2");
168 });
169
170 it("requests a fresh jump when the same search wraps to the same match from a new position", async () => {
171 queries.results.set(a, messageResult(0));
172 await act(async () => {
173 renderer = create(<Probe environmentId={a} />);
174 });
175 await act(async () => find.open());
176 find.timelineProps.findPositionReaderRef.current = () => ({
177 entryId: "reading",
178 occurrence: 0,
179 });
180 await act(async () => find.barProps.onQueryChange("COD4"));
181 const firstNavigationId = find.timelineProps.findNavigationId;
182 const firstMatch = find.timelineProps.activeFindMatch;
183 find.timelineProps.findPositionReaderRef.current = () => ({ entryId: "bottom", occurrence: 0 });
184 await act(async () => find.barProps.onQueryChange("COD4"));
185 expect(find.timelineProps.findNavigationId).toBeGreaterThan(firstNavigationId);
186 expect(find.timelineProps.activeFindMatch).toEqual(firstMatch);
187 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
188 environmentId: a,
189 input: { threadId, query: "COD4", skills: [], start: { entryId: "bottom", occurrence: 0 } },
190 });
191 const repeatedNavigationId = find.timelineProps.findNavigationId;
192 find.timelineProps.findPositionReaderRef.current = () => ({
193 entryId: "new-bottom",
194 occurrence: 0,
195 });
196 await act(async () => requestThreadFindOpen());
197 expect(find.timelineProps.findNavigationId).toBeGreaterThan(repeatedNavigationId);
198 expect(find.timelineProps.activeFindMatch).toEqual(firstMatch);
199 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
200 environmentId: a,
201 input: {
202 threadId,
203 query: "COD4",
204 skills: [],
205 start: { entryId: "new-bottom", occurrence: 0 },
206 },
207 });
208 });
209
210 it("cycles cached entries immediately, wraps, and accumulates clicks in one event", async () => {
211 const initial = {
212 ...messageResult(0),
213 totalMatches: 5,
214 match: { entryId: "first", runId, occurrence: 0 },
215 navigation: [
216 { entryId: "first", runId, startIndex: 0, count: 3 },
217 { entryId: "second", runId, startIndex: 3, count: 2 },
218 ],
219 };
220 queries.results.set(a, initial);
221 await act(async () => {
222 renderer = create(<Probe environmentId={a} />);
223 });
224 await act(async () => find.open());
225 await act(async () => find.barProps.onQueryChange("COD4"));
226 const requests = vi.mocked(orchestrationEnvironment.threadFind).mock.calls.length;
227 await act(async () => {
228 find.barProps.onNext();
229 find.barProps.onNext();
230 });
231 expect(find.barProps.activeIndex).toBe(2);
232 expect(find.timelineProps.activeFindMatch).toEqual({ entryId: "first", runId, occurrence: 2 });
233 expect(find.timelineProps.findNavigationId).toBe(3);
234 await act(async () => find.barProps.onNext());
235 expect(find.timelineProps.activeFindMatch).toEqual({ entryId: "second", runId, occurrence: 0 });
236 await act(async () => {
237 find.barProps.onNext();
238 find.barProps.onNext();
239 });
240 expect(find.barProps.activeIndex).toBe(0);
241 await act(async () => find.barProps.onPrevious());
242 expect(find.barProps.activeIndex).toBe(4);
243 expect(find.timelineProps.activeFindMatch).toEqual({ entryId: "second", runId, occurrence: 1 });
244 expect(orchestrationEnvironment.threadFind).toHaveBeenCalledTimes(requests);
245 expect(queries.refresh).not.toHaveBeenCalled();
246 });
247
248 it("does not let an older background response undo a cached step", async () => {
249 const initial = {
250 ...messageResult(0),
251 match: { entryId: "first", runId, occurrence: 0 },
252 navigation: [{ entryId: "first", runId, startIndex: 0, count: 3 }],
253 };
254 queries.results.set(a, initial);
255 await act(async () => {
256 renderer = create(<Probe environmentId={a} />);
257 });
258 await act(async () => find.open());
259 await act(async () => find.barProps.onQueryChange("COD4"));
260 const requests = vi.mocked(orchestrationEnvironment.threadFind).mock.calls.length;
261 await act(async () => find.barProps.onNext());
262 queries.results.set(a, {
263 ...initial,
264 snapshotSequence: 10,
265 totalMatches: 11,
266 activeIndex: 1,
267 navigation: [{ entryId: "first", runId, startIndex: 1, count: 3 }],
268 });
269 await act(async () => renderer?.update(<Probe environmentId={a} />));
270 expect(find.timelineProps.activeFindMatch).toEqual({ entryId: "first", runId, occurrence: 1 });
271 expect(find.barProps.activeIndex).toBe(2);
272 expect(find.barProps.matchCount).toBe(11);
273 expect(find.timelineProps.findNavigationId).toBe(2);
274 await act(async () => find.barProps.onNext());
275 expect(find.timelineProps.activeFindMatch?.occurrence).toBe(2);
276 expect(find.barProps.activeIndex).toBe(3);
277 expect(orchestrationEnvironment.threadFind).toHaveBeenCalledTimes(requests);
278 });
279
280 it("fetches the next window from the locally selected match and retains it while pending", async () => {
281 const initial = {
282 ...messageResult(0),
283 match: { entryId: "first", runId, occurrence: 0 },
284 navigation: [{ entryId: "first", runId, startIndex: 0, count: 2 }],
285 };
286 queries.results.set(a, initial);
287 await act(async () => {
288 renderer = create(<Probe environmentId={a} />);
289 });
290 await act(async () => find.open());
291 await act(async () => find.barProps.onQueryChange("COD4"));
292 await act(async () => find.barProps.onNext());
293 expect(find.timelineProps.activeFindMatch?.occurrence).toBe(1);
294 queries.pending = true;
295 queries.results.delete(a);
296 await act(async () => {
297 find.barProps.onNext();
298 find.barProps.onNext();
299 });
300 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
301 environmentId: a,
302 input: {
303 threadId,
304 query: "COD4",
305 skills: [],
306 start: { entryId: "first", occurrence: 1 },
307 offset: 2,
308 },
309 });
310 expect(find.barProps.activeIndex).toBe(1);
311 expect(find.timelineProps.findNavigationId).toBe(2);
312 queries.pending = false;
313 queries.results.set(a, messageResult(3));
314 await act(async () => renderer?.update(<Probe environmentId={a} />));
315 expect(find.barProps.activeIndex).toBe(3);
316 expect(find.timelineProps.findNavigationId).toBe(4);
317 });
318
319 it("refreshes around the cached selection when live matches are inserted before it", async () => {
320 vi.useFakeTimers();
321 const initial = {
322 ...messageResult(0),
323 match: { entryId: "first", runId, occurrence: 0 },
324 navigation: [
325 { entryId: "first", runId, startIndex: 0, count: 1 },
326 { entryId: "second", runId, startIndex: 1, count: 1 },
327 ],
328 };
329 queries.results.set(a, initial);
330 await act(async () => {
331 renderer = create(<Probe environmentId={a} />);
332 });
333 await act(async () => find.open());
334 await act(async () => find.barProps.onQueryChange("COD4"));
335 await act(async () => find.barProps.onNext());
336 expect(find.timelineProps.activeFindMatch?.entryId).toBe("second");
337 const content = { runs: [], visibleTurnItems: [] };
338 await act(async () => renderer?.update(<Probe environmentId={a} content={content} />));
339 await act(async () => vi.advanceTimersByTime(300));
340 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
341 environmentId: a,
342 input: { threadId, query: "COD4", skills: [], start: { entryId: "second", occurrence: 0 } },
343 });
344 queries.results.set(a, {
345 ...initial,
346 snapshotSequence: 10,
347 totalMatches: 11,
348 activeIndex: 2,
349 match: { entryId: "second", runId, occurrence: 0 },
350 navigation: [
351 { entryId: "inserted", runId, startIndex: 1, count: 1 },
352 { entryId: "second", runId, startIndex: 2, count: 1 },
353 ],
354 });
355 await act(async () => renderer?.update(<Probe environmentId={a} content={content} />));
356 expect(find.barProps.matchCount).toBe(11);
357 expect(find.barProps.activeIndex).toBe(2);
358 expect(find.timelineProps.activeFindMatch?.entryId).toBe("second");
359 expect(find.timelineProps.findNavigationId).toBe(2);
360 await act(async () => find.barProps.onPrevious());
361 expect(find.timelineProps.activeFindMatch?.entryId).toBe("inserted");
362 });
363
364 it("asks the server rather than using stale navigation counts during live updates", async () => {
365 const initial = {
366 ...messageResult(0),
367 navigation: [{ entryId: "message:2", runId, startIndex: 0, count: 10 }],
368 };
369 queries.results.set(a, initial);
370 await act(async () => {
371 renderer = create(<Probe environmentId={a} />);
372 });
373 await act(async () => find.open());
374 await act(async () => find.barProps.onQueryChange("COD4"));
375 await act(async () =>
376 renderer?.update(<Probe environmentId={a} content={{ runs: [], visibleTurnItems: [] }} />),
377 );
378 await act(async () => find.barProps.onNext());
379 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
380 environmentId: a,
381 input: {
382 threadId,
383 query: "COD4",
384 skills: [],
385 start: { entryId: "message:2", occurrence: 0 },
386 offset: 1,
387 },
388 });
389 });
390
391 it("starts from the viewport and steps from the server-selected match", async () => {
392 queries.results.set(a, messageResult(4));
393 await act(async () => {
394 renderer = create(<Probe environmentId={a} />);
395 });
396 await act(async () => find.open());
397 find.timelineProps.findPositionReaderRef.current = () => ({
398 entryId: "message:6",
399 occurrence: 1,
400 });
401 await act(async () => find.barProps.onQueryChange("COD4"));
402 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
403 environmentId: a,
404 input: {
405 threadId,
406 query: "COD4",
407 skills: [],
408 start: { entryId: "message:6", occurrence: 1 },
409 },
410 });
411 expect(find.barProps.activeIndex).toBe(4);
412 queries.results.set(a, messageResult(5));
413 await act(async () => find.barProps.onNext());
414 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
415 environmentId: a,
416 input: {
417 threadId,
418 query: "COD4",
419 skills: [],
420 start: { entryId: "message:6", occurrence: 0 },
421 offset: 1,
422 },
423 });
424 expect(find.timelineProps.activeFindMatch?.entryId).toBe("message:7");
425 queries.results.set(a, messageResult(4));
426 await act(async () => find.barProps.onPrevious());
427 expect(find.barProps.activeIndex).toBe(4);
428 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
429 environmentId: a,
430 input: {
431 threadId,
432 query: "COD4",
433 skills: [],
434 start: { entryId: "message:7", occurrence: 0 },
435 offset: -1,
436 },
437 });
438 });
439
440 it("accumulates rapid navigation against the same match until the response arrives", async () => {
441 queries.results.set(a, messageResult(4));
442 await act(async () => {
443 renderer = create(<Probe environmentId={a} />);
444 });
445 await act(async () => find.open());
446 await act(async () => find.barProps.onQueryChange("COD4"));
447 queries.pending = true;
448 queries.results.delete(a);
449 await act(async () => find.barProps.onNext());
450 await act(async () => find.barProps.onNext());
451 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
452 environmentId: a,
453 input: {
454 threadId,
455 query: "COD4",
456 skills: [],
457 start: { entryId: "message:6", occurrence: 0 },
458 offset: 2,
459 },
460 });
461 expect(find.timelineProps.findNavigationId).toBe(1);
462 queries.pending = false;
463 queries.results.set(a, messageResult(6));
464 await act(async () => renderer?.update(<Probe environmentId={a} />));
465 expect(find.timelineProps.findNavigationId).toBe(3);
466 expect(find.barProps.activeIndex).toBe(6);
467 });
468
469 it("refreshes continuous updates without shifting the current match or showing Searching", async () => {
470 vi.useFakeTimers();
471 queries.results.set(a, messageResult(4));
472 await act(async () => {
473 renderer = create(<Probe environmentId={a} />);
474 });
475 await act(async () => find.open());
476 await act(async () => find.barProps.onQueryChange("COD4"));
477 for (let i = 0; i < 3; i++) {
478 await act(async () =>
479 renderer?.update(<Probe environmentId={a} content={{ visibleTurnItems: [], runs: [] }} />),
480 );
481 await act(async () => vi.advanceTimersByTime(100));
482 }
483 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
484 environmentId: a,
485 input: {
486 threadId,
487 query: "COD4",
488 skills: [],
489 start: { entryId: "message:6", occurrence: 0 },
490 },
491 });
492 const updated = { ...messageResult(5, 10), totalMatches: 11, match: messageResult(4).match };
493 queries.results.set(a, updated);
494 await act(async () => renderer?.update(<Probe environmentId={a} />));
495 expect(find.barProps.matchCount).toBe(11);
496 expect(find.barProps.activeIndex).toBe(5);
497 expect(find.timelineProps.activeFindMatch?.entryId).toBe("message:6");
498 expect(find.timelineProps.findNavigationId).toBe(1);
499 expect(find.barProps.status).toBeNull();
500 await act(async () => find.barProps.onNext());
501 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
502 environmentId: a,
503 input: {
504 threadId,
505 query: "COD4",
506 skills: [],
507 start: { entryId: "message:6", occurrence: 0 },
508 offset: 1,
509 },
510 });
511 });
512
513 it("keeps pending navigation when incoming content triggers a refresh", async () => {
514 vi.useFakeTimers();
515 queries.results.set(a, messageResult(4));
516 await act(async () => {
517 renderer = create(<Probe environmentId={a} />);
518 });
519 await act(async () => find.open());
520 await act(async () => find.barProps.onQueryChange("COD4"));
521 queries.pending = true;
522 queries.results.delete(a);
523 await act(async () => find.barProps.onNext());
524 await act(async () =>
525 renderer?.update(<Probe environmentId={a} content={{ visibleTurnItems: [], runs: [] }} />),
526 );
527 await act(async () => vi.advanceTimersByTime(300));
528 expect(queries.refresh).toHaveBeenCalledTimes(1);
529 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
530 environmentId: a,
531 input: {
532 threadId,
533 query: "COD4",
534 skills: [],
535 start: { entryId: "message:6", occurrence: 0 },
536 offset: 1,
537 },
538 });
539 queries.pending = false;
540 queries.results.set(a, { ...messageResult(6, 10), totalMatches: 11 });
541 await act(async () => renderer?.update(<Probe environmentId={a} />));
542 expect(find.timelineProps.activeFindMatch?.entryId).toBe("message:8");
543 expect(find.timelineProps.findNavigationId).toBe(2);
544 expect(find.barProps.matchCount).toBe(11);
545 });
546
547 it("does not navigate the previous result while a new match is loading", async () => {
548 queries.results.set(a, messageResult(0));
549 await act(async () => {
550 renderer = create(<Probe environmentId={a} />);
551 });
552 await act(async () => find.open());
553 await act(async () => find.barProps.onQueryChange("COD4"));
554 queries.pending = true;
555 queries.results.delete(a);
556 await act(async () => find.barProps.onNext());
557 expect(find.timelineProps.activeFindMatch?.entryId).toBe("message:2");
558 expect(find.timelineProps.findNavigationId).toBe(1);
559 expect(find.barProps.status).toBeNull();
560 expect(find.barProps.activeIndex).toBe(0);
561 expect(find.barProps.matchCount).toBe(10);
562 queries.pending = false;
563 queries.results.set(a, messageResult(1));
564 await act(async () => renderer?.update(<Probe environmentId={a} />));
565 expect(find.timelineProps.activeFindMatch?.entryId).toBe("message:3");
566 expect(find.timelineProps.findNavigationId).toBe(2);
567 });
568
569 it("keeps the reading-position start when Enter is pressed before results arrive", async () => {
570 queries.pending = true;
571 await act(async () => {
572 renderer = create(<Probe environmentId={a} />);
573 });
574 await act(async () => find.open());
575 find.timelineProps.findPositionReaderRef.current = () => ({
576 entryId: "message:6",
577 occurrence: 1,
578 });
579 await act(async () => find.barProps.onQueryChange("COD4"));
580 await act(async () => find.barProps.onNext());
581 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
582 environmentId: a,
583 input: {
584 threadId,
585 query: "COD4",
586 skills: [],
587 start: { entryId: "message:6", occurrence: 1 },
588 },
589 });
590 });
591
592 it("shows Searching only when the query has no result yet", async () => {
593 queries.pending = true;
594 await act(async () => {
595 renderer = create(<Probe environmentId={a} />);
596 });
597 await act(async () => find.open());
598 await act(async () => find.barProps.onQueryChange("COD4"));
599 expect(find.barProps.status).toBe("loading");
600 queries.pending = false;
601 queries.results.set(a, messageResult(0));
602 await act(async () => renderer?.update(<Probe environmentId={a} />));
603 expect(find.barProps.status).toBeNull();
604 queries.pending = true;
605 queries.results.delete(a);
606 await act(async () => find.barProps.onQueryChange("different query"));
607 expect(find.barProps.status).toBe("loading");
608 expect(find.barProps.matchCount).toBe(0);
609 // Folded content stays expanded while the new query loads.
610 expect(find.timelineProps.findQuery).toBe("");
611 expect(find.timelineProps.findExpanded).toBe(true);
612 await act(async () => find.barProps.onQueryChange(""));
613 expect(find.timelineProps.findExpanded).toBe(false);
614 });
615
616 it("keeps search unavailable when the server does not support it", async () => {
617 await act(async () => {
618 renderer = create(<Probe environmentId={a} enabled={false} />);
619 });
620 await act(async () => {
621 find.open();
622 requestThreadFindOpen();
623 });
624 expect(find.isOpen).toBe(false);
625 expect(find.timelineProps.activeFindMatch).toBeNull();
626 expect(orchestrationEnvironment.threadFind).not.toHaveBeenCalled();
627 });
628
629 it("closes search when support disappears without restoring stale queries", async () => {
630 await act(async () => {
631 renderer = create(<Probe environmentId={a} />);
632 });
633 await act(async () => find.open());
634 await act(async () => find.barProps.onQueryChange("needle"));
635 expect(find.isOpen).toBe(true);
636 vi.mocked(orchestrationEnvironment.threadFind).mockClear();
637 await act(async () => {
638 renderer?.update(<Probe environmentId={a} enabled={false} />);
639 });
640 expect(find.isOpen).toBe(false);
641 expect(find.timelineProps.activeFindMatch).toBeNull();
642 expect(find.timelineProps.findQuery).toBe("");
643 expect(orchestrationEnvironment.threadFind).not.toHaveBeenCalled();
644 await act(async () => {
645 renderer?.update(<Probe environmentId={a} />);
646 });
647 expect(find.isOpen).toBe(false);
648 await act(async () => find.open());
649 expect(find.barProps.query).toBe("");
650 });
651
652 it("preserves plan item ID and run ownership for navigation", async () => {
653 queries.results.set(a, {
654 snapshotSequence: 9,
655 totalMatches: 1,
656 activeIndex: 0,
657 match: { entryId: "plan-item", runId, occurrence: 0 },
658 });
659 await act(async () => {
660 renderer = create(<Probe environmentId={a} />);
661 });
662 await act(async () => {
663 find.open();
664 });
665 await act(async () => {
666 find.barProps.onQueryChange("needle");
667 });
668 expect(find.timelineProps.activeFindMatch).toEqual({
669 entryId: "plan-item",
670 runId,
671 occurrence: 0,
672 });
673 await act(async () => {
674 find.close();
675 });
676 expect(find.timelineProps.activeFindMatch).toBeNull();
677 expect(find.timelineProps.findQuery).toBe("");
678 });
679
680 it("drops results on environment changes even when the thread IDs are identical", async () => {
681 queries.results.set(a, {
682 snapshotSequence: 1,
683 totalMatches: 3,
684 activeIndex: 0,
685 match: null,
686 });
687 queries.results.set(b, {
688 snapshotSequence: 2,
689 totalMatches: 0,
690 activeIndex: 0,
691 match: null,
692 });
693 await act(async () => {
694 renderer = create(<Probe environmentId={a} />);
695 });
696 await act(async () => {
697 find.open();
698 });
699 await act(async () => {
700 find.barProps.onQueryChange("needle");
701 });
702 expect(find.barProps.matchCount).toBe(3);
703 await act(async () => {
704 renderer?.update(<Probe environmentId={b} />);
705 });
706 expect(find.isOpen).toBe(false);
707 expect(find.timelineProps.activeFindMatch).toBeNull();
708 await act(async () => {
709 find.open();
710 });
711 await act(async () => {
712 find.barProps.onQueryChange("needle");
713 });
714 expect(find.barProps.matchCount).toBe(0);
715 await act(async () => {
716 renderer?.update(<Probe environmentId={a} />);
717 });
718 expect(find.isOpen).toBe(false);
719 });
720});