diff --git a/bridged/src/test/java/dev/ltms/bridged/inject/CompletionResolverTest.java b/bridged/src/test/java/dev/ltms/bridged/inject/CompletionResolverTest.java index 17a27f8..c46d5d6 100644 --- a/bridged/src/test/java/dev/ltms/bridged/inject/CompletionResolverTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/inject/CompletionResolverTest.java @@ -195,6 +195,66 @@ class CompletionResolverTest { assertEquals("hello", waiter.getNow(null).text()); } + @Test + void resolvesWhenTheScrapeItselfFailsEvenWithABaselinePresent() { + // The most important branch of the CB-115 guard: a failed read means the resolver could not + // SEE the screen — "couldn't see", not "no change". It must still resolve the send (an empty + // tail beats hanging until the caller's timeout), even though a baseline was captured. The + // baseline here is "" (an empty pane at delivery), so without the !scrapeFailed clause the + // byte-identical guard would wrongly match the empty tail and suppress. + FakeHerdr herdr = new FakeHerdr().healthy(false); // agent.read throws HerdrException + Rendezvous rendezvous = new Rendezvous(); + CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous); + + var waiter = rendezvous.open("term_a"); + var turn = new CompletionResolver.InFlight(waiter, ""); // empty pane baselined at delivery + resolver.resolve("term_a", turn); + + assertTrue(waiter.isDone(), + "a failed scrape must still resolve the send, not hang until the caller's timeout"); + assertEquals(Rendezvous.Kind.COMPLETION, waiter.getNow(null).kind()); + assertEquals("", waiter.getNow(null).text(), "the tail is empty because the screen was unreadable"); + } + + // --- CB-115/CB-116 fail guard: an already-done or absent waiter is left alone --------- + + @Test + void failLeavesAnAlreadyResolvedWaiterUntouchedAndSkipsTheScrape() { + // The send was already resolved (e.g. by the worker's explicit reply) before fail fired. + // fail must not overwrite that value, and must not even scrape the worker — nobody needs it. + FakeHerdr herdr = new FakeHerdr().readText("an error screen"); + Rendezvous rendezvous = new Rendezvous(); + CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous); + + var waiter = rendezvous.open("term_a"); + var turn = new CompletionResolver.InFlight(waiter, null); + assertTrue(rendezvous.resolveCompletion(waiter, "already replied")); + + resolver.fail("term_a", turn); + + assertFalse(herdr.called("agent.read"), + "fail must not scrape a waiter that is already done"); + assertEquals(Rendezvous.Kind.COMPLETION, waiter.getNow(null).kind(), + "fail must not overwrite the existing resolution"); + assertEquals("already replied", waiter.getNow(null).text()); + } + + @Test + void failFallsBackToTheRegisteredWaiterWhenThereIsNoInFlightTurn() { + // A never-delivered readiness failure has no in-flight record but still has a blocked send; + // fail falls back to the waiter currently registered on the Rendezvous and fails it. + FakeHerdr herdr = new FakeHerdr().readText("stuck on an error screen"); + Rendezvous rendezvous = new Rendezvous(); + CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous); + + var waiter = rendezvous.open("term_a"); // send registered, but no captureBaseline ever ran + resolver.fail("term_a", null); // no in-flight turn → fall back to the registered waiter + + assertTrue(waiter.isDone(), "fail falls back to the registered waiter when no turn is in flight"); + assertEquals(Rendezvous.Kind.FAILED, waiter.getNow(null).kind()); + assertEquals("stuck on an error screen", waiter.getNow(null).text()); + } + // --- CB-116 waiter identity: a late completion never crosses into the next turn --------- @Test diff --git a/bridged/src/test/java/dev/ltms/bridged/msg/RendezvousTest.java b/bridged/src/test/java/dev/ltms/bridged/msg/RendezvousTest.java index 61e1a58..927a169 100644 --- a/bridged/src/test/java/dev/ltms/bridged/msg/RendezvousTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/msg/RendezvousTest.java @@ -70,6 +70,28 @@ class RendezvousTest { "no blocked send means no primary to surface the question to"); } + @Test + void resolveCompletionTwiceIsANoOpTheSecondTime() { + CompletableFuture waiter = rendezvous.open(W); + assertTrue(rendezvous.resolveCompletion(waiter, "first scrape"), "the first completion resolves"); + assertFalse(rendezvous.resolveCompletion(waiter, "second scrape"), + "a second completion on an already-resolved waiter returns false"); + assertEquals(Rendezvous.Kind.COMPLETION, waiter.getNow(null).kind()); + assertEquals("first scrape", waiter.getNow(null).text(), + "the first resolution wins; the stored value is unchanged"); + } + + @Test + void resolveFailureTwiceIsANoOpTheSecondTime() { + CompletableFuture waiter = rendezvous.open(W); + assertTrue(rendezvous.resolveFailure(waiter, "first reason"), "the first failure resolves"); + assertFalse(rendezvous.resolveFailure(waiter, "second reason"), + "a second failure on an already-resolved waiter returns false"); + assertEquals(Rendezvous.Kind.FAILED, waiter.getNow(null).kind()); + assertEquals("first reason", waiter.getNow(null).text(), + "the first resolution wins; the stored value is unchanged"); + } + @Test void closeAskRemovesTheTurn() { Rendezvous.AskTicket t = rendezvous.openAsk(W);