diff --git a/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChange.java b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChange.java deleted file mode 100644 index b9ff62761ae..00000000000 --- a/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChange.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2000-2026 Vaadin Ltd. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ -package com.vaadin.flow.component.ai.form; - -import java.io.Serializable; - -import com.vaadin.flow.component.HasValue; - -/** - * Captures the before / after values of a single field across one - * {@link FormAIController} turn. Reported through - * {@link FormAIController#addFieldValueChangedListener} for every field whose - * value changed during the turn — either written by the LLM or by a cascade. - * - * @param field - * the field whose value changed - * @param oldValue - * the field's value at the start of the turn, possibly {@code null} - * @param newValue - * the field's value at the end of the turn, possibly {@code null} - * - * @author Vaadin Ltd - * @since 25.2 - */ -public record FieldValueChange(HasValue field, Object oldValue, - Object newValue) implements Serializable { -} diff --git a/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChangeEvent.java b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChangeEvent.java new file mode 100644 index 00000000000..e5e1f145ef3 --- /dev/null +++ b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChangeEvent.java @@ -0,0 +1,99 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.component.ai.form; + +import java.io.Serializable; +import java.util.Objects; + +import com.vaadin.flow.component.HasValue; + +/** + * Fired by {@link FormAIController} once per field whose value changed during a + * successful AI turn — either written by the LLM or by a cascade. Events fire + * in document order, after every field's post-turn value has been applied. A + * field whose post-turn value equals its pre-turn value (by + * {@link Objects#equals(Object, Object)}) does not produce an event. No events + * fire when the turn ended in error. + * + * @since 25.3 + */ +public final class FieldValueChangeEvent implements Serializable { + + private final transient FormAIController source; + private final HasValue field; + @SuppressWarnings("java:S1948") + private final Object oldValue; + @SuppressWarnings("java:S1948") + private final Object newValue; + + FieldValueChangeEvent(FormAIController source, HasValue field, + Object oldValue, Object newValue) { + this.source = Objects.requireNonNull(source, "Source must not be null"); + this.field = Objects.requireNonNull(field, "Field must not be null"); + this.oldValue = oldValue; + this.newValue = newValue; + } + + /** + * Returns the controller that produced this event. Listeners can use this + * to call back into the controller (e.g. + * {@link FormAIController#showFieldHighlight}) without capturing it from + * the registration site. + *

+ * The source is transient, so it is {@code null} on an event restored from + * a serialized session. + * + * @return the source controller, or {@code null} if this event was + * deserialized + */ + public FormAIController getSource() { + return source; + } + + /** + * Returns the field whose value changed during this turn. + * + * @return the changed field, never {@code null} + */ + @SuppressWarnings("java:S1452") + public HasValue getField() { + return field; + } + + /** + * Returns the field's value at the start of the turn, before the LLM ran. + * For a field that was hidden at turn start and revealed during the turn, + * this is the field's actual pre-turn value rather than {@code null}. For a + * field added to the form during the turn, this is the field's + * {@link HasValue#getEmptyValue() empty value} — the field had no pre-turn + * value to report. + * + * @return the pre-turn value, possibly {@code null} + */ + public Object getOldValue() { + return oldValue; + } + + /** + * Returns the field's value at the end of the turn, after the LLM's writes + * and any cascading value-change listeners have settled. + * + * @return the post-turn value, possibly {@code null} + */ + public Object getNewValue() { + return newValue; + } +} diff --git a/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChangeListener.java b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChangeListener.java new file mode 100644 index 00000000000..6835a13bdf5 --- /dev/null +++ b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChangeListener.java @@ -0,0 +1,37 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.component.ai.form; + +import java.io.Serializable; + +/** + * Listener for {@link FieldValueChangeEvent}s fired by a + * {@link FormAIController} after a successful AI turn. One event is delivered + * per changed field, in document order. + * + * @since 25.3 + */ +@FunctionalInterface +public interface FieldValueChangeListener extends Serializable { + + /** + * Invoked once per changed field after the AI turn has settled. + * + * @param event + * the event describing the change, never {@code null} + */ + void onFieldValueChange(FieldValueChangeEvent event); +} diff --git a/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FormAIController.java b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FormAIController.java index 4fdcf0da843..67625014b42 100644 --- a/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FormAIController.java +++ b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FormAIController.java @@ -47,7 +47,6 @@ import com.vaadin.flow.component.ai.provider.LLMProvider; import com.vaadin.flow.data.binder.Binder; import com.vaadin.flow.data.selection.MultiSelect; -import com.vaadin.flow.function.SerializableConsumer; import com.vaadin.flow.internal.JacksonUtils; import com.vaadin.flow.shared.Registration; @@ -155,10 +154,10 @@ * *

* Change tracking and highlight: a listener registered through - * {@link #addFieldValueChangedListener(SerializableConsumer)} fires once per - * successful turn with the fields whose value changed during the turn — the - * common driver for {@link #showFieldHighlight(HasValue)} / - * {@link #hideFieldHighlight} to flash the AI's edits in the UI. + * {@link #addFieldValueChangeListener(FieldValueChangeListener)} fires once per + * field whose value changed during a successful turn — the common driver for + * {@link #showFieldHighlight(HasValue)} / {@link #hideFieldHighlight} to flash + * the AI's edits in the UI. *

* *

@@ -269,7 +268,7 @@ spans multiple fields (e.g. start date must precede end date). \ * {@link #hideFieldHighlight}. */ private final Map, Registration> highlightedFields = new HashMap<>(); - private final List>> fieldValuesChangedListeners = new ArrayList<>(); + private final List fieldValueChangeListeners = new ArrayList<>(); /** * Creates a new form AI controller for the given container. Fields are @@ -613,28 +612,32 @@ public boolean isFieldValuesHidden() { } /** - * Registers a listener that is invoked once per successful turn with the - * fields whose value differs from what was read at the start of the turn. + * Registers a listener that is invoked once per field whose value changed + * during a successful AI turn. The listener fires once per changed field, + * in document order, after every field's post-turn value has been applied. * Comparison is by {@link Objects#equals(Object, Object)} so multi-select * sets, dates, and other value-objects work naturally. *

- * Multiple listeners are supported and fire in registration order. If one - * listener throws, the exception is logged and the remaining listeners - * still fire. + * Multiple listeners are supported. For each changed field, every listener + * fires in registration order before the next field's event is dispatched. + * If one listener throws, the exception is logged and the remaining + * listeners still fire — both for that change and for subsequent changes in + * the same turn. *

- * Only non-ignored fields are tracked, and only changed fields appear in - * the list. A field's pre-turn value is captured regardless of its current - * visibility, so a value cascaded into a freshly-revealed field is reported - * with the field's real pre-turn value rather than a spurious {@code null}. - * The listener is not called when the turn ended in error or when no field - * changed. The list iterates in document order; modifying it has no effect - * on the controller. + * Only non-ignored fields are tracked, and only fields whose value differs + * at end-of-turn produce events. A field's pre-turn value is captured + * regardless of its current visibility, so a value cascaded into a + * freshly-revealed field is reported with the field's real pre-turn value + * rather than a spurious {@code null}. A field added to the form during the + * turn is compared against its {@link HasValue#getEmptyValue() empty + * value}. No events fire when the turn ended in error. *

- * The listener runs on the UI thread with the session lock held, so it can + * Listeners run on the UI thread with the session lock held, so they can * update components and call {@link #showFieldHighlight} / * {@link #hideFieldHighlight} directly without {@code ui.access(...)}. A * typical use is to flash the AI's edits by calling - * {@code showFieldHighlight} on every changed field. + * {@code showFieldHighlight} on every event's + * {@link FieldValueChangeEvent#getField field}. * * @param listener * the listener to register, not {@code null} @@ -642,11 +645,11 @@ public boolean isFieldValuesHidden() { * @throws NullPointerException * if {@code listener} is {@code null} */ - public Registration addFieldValueChangedListener( - SerializableConsumer> listener) { + public Registration addFieldValueChangeListener( + FieldValueChangeListener listener) { Objects.requireNonNull(listener, "Listener must not be null"); - fieldValuesChangedListeners.add(listener); - return () -> fieldValuesChangedListeners.remove(listener); + fieldValueChangeListeners.add(listener); + return () -> fieldValueChangeListeners.remove(listener); } /** @@ -792,7 +795,7 @@ private void refreshValueOptionsBindings() { @Override public void onResponse(Throwable error) { try { - fireFieldValuesChanged(error); + fireFieldValueChanges(error); } finally { // Unlock regardless of success or failure: locks set in onRequest // must be released so the user can edit again. The failure path @@ -804,7 +807,7 @@ public void onResponse(Throwable error) { /** * Captures the current value of every known field before the LLM runs. The * snapshot is consulted in {@link #onResponse} to compute the before / - * after diff for {@link #addFieldValueChangedListener}. Skipped when no + * after diff for {@link #addFieldValueChangeListener}. Skipped when no * listener is registered to avoid copying values that no one will read. *

* Hidden and disabled fields are included so a value cascaded into a field @@ -813,7 +816,7 @@ public void onResponse(Throwable error) { */ private void snapshotPreTurnValues() { preTurnValues.clear(); - if (fieldValuesChangedListeners.isEmpty()) { + if (fieldValueChangeListeners.isEmpty()) { return; } for (var field : collectKnownFields()) { @@ -822,43 +825,58 @@ private void snapshotPreTurnValues() { } /** - * Builds the change list from the pre-turn snapshot and the post-turn value - * of every known field, then invokes every registered listener if anything - * changed. The post-turn walk picks up fields that were hidden (or absent) - * at turn start but became visible / were added during the turn, so - * visibility cascades report their value changes correctly. On error the - * snapshot is discarded and no listener fires — the application learns - * about errors through the orchestrator's response listener instead. A - * throwing listener is logged and otherwise ignored so subsequent listeners - * still fire and the rest of the response lifecycle (notably + * Walks the pre-turn snapshot against the post-turn value of every known + * field and fires one {@link FieldValueChangeEvent} per changed field, in + * document order. The post-turn walk picks up fields that were hidden (or + * absent) at turn start but became visible / were added during the turn, so + * visibility cascades report their value changes correctly. A field with no + * snapshot entry (added mid-turn) compares against its empty value, so + * adding a field that keeps its empty value does not produce an event. + *

+ * The diff is materialised before any listener runs, so a listener that + * writes to a tracked field cannot retroactively change the + * {@code newValue} another field's event carries. The listener set is also + * snapshotted once per turn, so adding or removing listeners mid-dispatch + * (including a listener removing itself) affects only subsequent turns, not + * the rest of the current turn. + *

+ * On error the snapshot is discarded and no events fire — the application + * learns about errors through the orchestrator's response listener instead. + * A throwing listener is logged and otherwise ignored so subsequent + * listeners still fire, subsequent change events in the same turn still + * fire, and the rest of the response lifecycle (notably * {@link #unlockFields}) still runs. */ - private void fireFieldValuesChanged(Throwable error) { + private void fireFieldValueChanges(Throwable error) { if (preTurnValues.isEmpty() || error != null) { preTurnValues.clear(); return; } - var changes = new ArrayList(); + var events = new ArrayList(); for (var field : collectKnownFields()) { - var oldValue = preTurnValues.get(field); + var oldValue = preTurnValues.containsKey(field) + ? preTurnValues.get(field) + : field.getEmptyValue(); var newValue = field.getValue(); if (!Objects.equals(oldValue, newValue)) { - changes.add(new FieldValueChange(field, oldValue, newValue)); + events.add(new FieldValueChangeEvent(this, field, oldValue, + newValue)); } } preTurnValues.clear(); - if (changes.isEmpty()) { + if (events.isEmpty()) { return; } - // Snapshot the list before iterating so a listener that adds or - // removes listeners (its own Registration included) doesn't break - // the dispatch. - for (var listener : List.copyOf(fieldValuesChangedListeners)) { - try { - listener.accept(changes); - } catch (Exception ex) { - LOGGER.warn("Field-values-changed listener threw an exception", - ex); + var snapshot = List.copyOf(fieldValueChangeListeners); + for (var event : events) { + for (var listener : snapshot) { + try { + listener.onFieldValueChange(event); + } catch (Exception ex) { + LOGGER.warn( + "Field-value-change listener threw an exception", + ex); + } } } } @@ -912,8 +930,8 @@ private void lockFields() { * tracks — i.e. all discovered fields minus those hidden via * {@link #ignoreField(HasValue)}. Visibility and enabled state are NOT * filtered, so this is the right set for the snapshot + diff used by - * {@link #addFieldValueChangedListener}: a field hidden at turn start may - * be revealed during the turn, and a value cascaded into it should compare + * {@link #addFieldValueChangeListener}: a field hidden at turn start may be + * revealed during the turn, and a value cascaded into it should compare * against its real pre-turn value rather than {@code null}. */ private List> collectKnownFields() { diff --git a/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/test/java/com/vaadin/flow/component/ai/form/FormAIControllerTest.java b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/test/java/com/vaadin/flow/component/ai/form/FormAIControllerTest.java index ba7d6c93ca9..ef00117b915 100644 --- a/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/test/java/com/vaadin/flow/component/ai/form/FormAIControllerTest.java +++ b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/test/java/com/vaadin/flow/component/ai/form/FormAIControllerTest.java @@ -22,6 +22,7 @@ import static com.vaadin.flow.component.ai.form.FormTestSupport.json; import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; @@ -1029,26 +1030,26 @@ void seedingSkipsNonComponentHasValue() { } @Nested - class FieldValuesChangedListener { + class FieldValueChange { @Test - void listenerFiresWithChangedFieldsAfterSuccessfulTurn() { + void listenerFiresWithChangedFieldAfterSuccessfulTurn() { var field = new TestField(); var controller = new FormAIController(new Div(field)); - var captured = new AtomicReference>(); - controller.addFieldValueChangedListener(captured::set); + var events = new ArrayList(); + controller.addFieldValueChangeListener(events::add); controller.onRequest(); field.setValue("John"); controller.onResponse(null); - var changes = captured.get(); - Assertions.assertNotNull(changes, "Listener must fire when a " - + "field's value changed during the turn"); - Assertions.assertEquals(1, changes.size()); - var change = changeFor(changes, field); - Assertions.assertEquals("", change.oldValue()); - Assertions.assertEquals("John", change.newValue()); + Assertions.assertEquals(1, events.size(), "Listener must fire " + + "exactly once for the single changed field"); + var event = eventFor(events, field); + Assertions.assertEquals("", event.getOldValue()); + Assertions.assertEquals("John", event.getNewValue()); + Assertions.assertSame(controller, event.getSource(), + "Event source must be the controller that produced it"); } @Test @@ -1056,8 +1057,8 @@ void listenerNotInvokedWhenNoFieldChanged() { var field = new TestField(); var controller = new FormAIController(new Div(field)); var invocations = new AtomicInteger(); - controller.addFieldValueChangedListener( - c -> invocations.incrementAndGet()); + controller.addFieldValueChangeListener( + e -> invocations.incrementAndGet()); controller.onRequest(); // No setValue between request and response. @@ -1072,8 +1073,8 @@ void listenerNotInvokedOnError() { var field = new TestField(); var controller = new FormAIController(new Div(field)); var invocations = new AtomicInteger(); - controller.addFieldValueChangedListener( - c -> invocations.incrementAndGet()); + controller.addFieldValueChangeListener( + e -> invocations.incrementAndGet()); controller.onRequest(); field.setValue("partial"); @@ -1085,48 +1086,45 @@ void listenerNotInvokedOnError() { } @Test - void listContainsOnlyChangedFields() { + void onlyChangedFieldsProduceEvents() { var changed = new TestField(); var untouched = new TestField(); var controller = new FormAIController(new Div(changed, untouched)); - var captured = new AtomicReference>(); - controller.addFieldValueChangedListener(captured::set); + var events = new ArrayList(); + controller.addFieldValueChangeListener(events::add); controller.onRequest(); changed.setValue("X"); controller.onResponse(null); - var changes = captured.get(); - Assertions.assertEquals(1, changes.size(), - "Only the changed field must appear; got: " + changes); - Assertions.assertTrue(containsChangeFor(changes, changed)); - Assertions.assertFalse(containsChangeFor(changes, untouched)); + Assertions.assertEquals(1, events.size(), + "Only the changed field must produce an event; got: " + + events); + Assertions.assertTrue(containsEventFor(events, changed)); + Assertions.assertFalse(containsEventFor(events, untouched)); } @Test - void ignoredFieldsDoNotAppearEvenIfTheirValueChanged() { + void ignoredFieldsDoNotProduceEventsEvenIfTheirValueChanged() { // Application-driven cascades into a field marked ignoreField() - // must - // not leak into the change list — ignoreField() is the - // application's - // opt-out from AI-driven tracking on either side of the lifecycle. + // must not leak into the event stream — ignoreField() is the + // application's opt-out from AI-driven tracking on either side + // of the lifecycle. var visible = new TestField(); var ignored = new TestField(); visible.addValueChangeListener(e -> ignored.setValue("cascade")); var controller = new FormAIController(new Div(visible, ignored)); controller.ignoreField(ignored); - var captured = new AtomicReference>(); - controller.addFieldValueChangedListener(captured::set); + var events = new ArrayList(); + controller.addFieldValueChangeListener(events::add); controller.onRequest(); visible.setValue("primary"); controller.onResponse(null); - var changes = captured.get(); - Assertions.assertTrue(containsChangeFor(changes, visible)); - Assertions.assertFalse(containsChangeFor(changes, ignored), - "Ignored fields must not appear in the change list; got: " - + changes); + Assertions.assertTrue(containsEventFor(events, visible)); + Assertions.assertFalse(containsEventFor(events, ignored), + "Ignored fields must not produce events; got: " + events); } @Test @@ -1147,7 +1145,7 @@ void listenerExceptionStillReleasesFieldLocks() { // outcome: a stuck-locked field strands the user. var field = new TestField(); var controller = new FormAIController(new Div(field)); - controller.addFieldValueChangedListener(c -> { + controller.addFieldValueChangeListener(e -> { throw new RuntimeException("listener boom"); }); @@ -1160,73 +1158,73 @@ void listenerExceptionStillReleasesFieldLocks() { } @Test - void cascadingChangesAppearInTheSameTurn() { - // Cascades through ValueChangeListener are observable in the diff - // regardless of who triggered them — pin the symmetry so this - // doesn't quietly regress to "only AI-driven writes are reported". + void cascadingChangesProduceSeparateEventsInTheSameTurn() { + // Cascades through ValueChangeListener are observable in the + // event stream regardless of who triggered them — pin the + // symmetry so this doesn't quietly regress to "only AI-driven + // writes are reported". var primary = new TestField(); var cascaded = new TestField(); primary.addValueChangeListener(e -> cascaded.setValue("derived")); var controller = new FormAIController(new Div(primary, cascaded)); - var captured = new AtomicReference>(); - controller.addFieldValueChangedListener(captured::set); + var events = new ArrayList(); + controller.addFieldValueChangeListener(events::add); controller.onRequest(); primary.setValue("driver"); controller.onResponse(null); - var changes = captured.get(); - Assertions.assertEquals(2, changes.size(), - "Both the driver and cascaded fields must be reported; " - + "got: " + changes); + Assertions.assertEquals(2, events.size(), + "Both the driver and cascaded fields must produce events; " + + "got: " + events); Assertions.assertEquals("derived", - changeFor(changes, cascaded).newValue()); + eventFor(events, cascaded).getNewValue()); } @Test - void multiSelectSetWithEqualContentIsNotReportedAsChange() { + void multiSelectSetWithEqualContentDoesNotProduceEvent() { var field = new FormTestFields.MultiSelectField(); field.setValue(Set.of("a", "b")); var controller = new FormAIController(new Div(field)); - var captured = new AtomicReference>(); - controller.addFieldValueChangedListener(captured::set); + var events = new ArrayList(); + controller.addFieldValueChangeListener(events::add); controller.onRequest(); // Same content, different Set instance — Objects.equals true. field.setValue(Set.of("b", "a")); controller.onResponse(null); - Assertions.assertNull(captured.get(), - "A multi-select set equal to its previous value must not " - + "be reported as a change"); + Assertions.assertTrue(events.isEmpty(), + "A multi-select set equal to its previous value must " + + "not produce an event; got: " + events); } @Test - void multiSelectSetWithDifferentContentIsReportedAsChange() { + void multiSelectSetWithDifferentContentProducesEvent() { var field = new FormTestFields.MultiSelectField(); field.setValue(Set.of("a", "b")); var controller = new FormAIController(new Div(field)); - var captured = new AtomicReference>(); - controller.addFieldValueChangedListener(captured::set); + var events = new ArrayList(); + controller.addFieldValueChangeListener(events::add); controller.onRequest(); field.setValue(Set.of("a", "c")); controller.onResponse(null); - var change = changeFor(captured.get(), field); - Assertions.assertEquals(Set.of("a", "b"), change.oldValue()); - Assertions.assertEquals(Set.of("a", "c"), change.newValue()); + var event = eventFor(events, field); + Assertions.assertEquals(Set.of("a", "b"), event.getOldValue()); + Assertions.assertEquals(Set.of("a", "c"), event.getNewValue()); } @Test - void changeListIteratesInDocumentOrder() { + void eventsArriveInDocumentOrder() { var first = new TestField(); var second = new TestField(); var third = new TestField(); var controller = new FormAIController( new Div(first, second, third)); - var captured = new AtomicReference>(); - controller.addFieldValueChangedListener(captured::set); + var events = new ArrayList(); + controller.addFieldValueChangeListener(events::add); controller.onRequest(); third.setValue("c"); @@ -1235,49 +1233,49 @@ void changeListIteratesInDocumentOrder() { controller.onResponse(null); Assertions.assertEquals(List.of(first, second, third), - captured.get().stream().map(FieldValueChange::field) + events.stream().map(FieldValueChangeEvent::getField) .toList(), - "List iteration must follow document order regardless of " - + "the order writes happened in"); + "Events must fire in document order regardless of the " + + "order writes happened in"); } @Test void nullPreTurnValueIsReportedFaithfully() { var field = new FormTestFields.DateField(); var controller = new FormAIController(new Div(field)); - var captured = new AtomicReference>(); - controller.addFieldValueChangedListener(captured::set); + var events = new ArrayList(); + controller.addFieldValueChangeListener(events::add); controller.onRequest(); field.setValue(LocalDate.of(2026, 1, 1)); controller.onResponse(null); - var change = changeFor(captured.get(), field); - Assertions.assertNull(change.oldValue(), + var event = eventFor(events, field); + Assertions.assertNull(event.getOldValue(), "Pre-turn null must round-trip as null"); Assertions.assertEquals(LocalDate.of(2026, 1, 1), - change.newValue()); + event.getNewValue()); } @Test - void clearingAValueToNullIsReportedAsChange() { + void clearingAValueToNullProducesEvent() { // Inverse of nullPreTurnValueIsReportedFaithfully: pre-turn // non-null → post-turn null must surface as a change so // applications can react (e.g. clear the highlight). var field = new FormTestFields.DateField(); field.setValue(LocalDate.of(2026, 1, 1)); var controller = new FormAIController(new Div(field)); - var captured = new AtomicReference>(); - controller.addFieldValueChangedListener(captured::set); + var events = new ArrayList(); + controller.addFieldValueChangeListener(events::add); controller.onRequest(); field.setValue(null); controller.onResponse(null); - var change = changeFor(captured.get(), field); + var event = eventFor(events, field); Assertions.assertEquals(LocalDate.of(2026, 1, 1), - change.oldValue()); - Assertions.assertNull(change.newValue(), + event.getOldValue()); + Assertions.assertNull(event.getNewValue(), "Clearing to null must surface as the new value"); } @@ -1285,20 +1283,22 @@ void clearingAValueToNullIsReportedAsChange() { void multipleListenersAllFire() { var field = new TestField(); var controller = new FormAIController(new Div(field)); - var first = new AtomicReference>(); - var second = new AtomicReference>(); - controller.addFieldValueChangedListener(first::set); - controller.addFieldValueChangedListener(second::set); + var first = new ArrayList(); + var second = new ArrayList(); + controller.addFieldValueChangeListener(first::add); + controller.addFieldValueChangeListener(second::add); controller.onRequest(); field.setValue("X"); controller.onResponse(null); - Assertions.assertNotNull(first.get(), "First listener must fire"); - Assertions.assertNotNull(second.get(), - "Second listener must also fire"); - Assertions.assertEquals(first.get(), second.get(), - "Both listeners must receive equal change lists"); + Assertions.assertEquals(1, first.size(), + "First listener must fire once for the changed field"); + Assertions.assertEquals(1, second.size(), + "Second listener must also fire once"); + Assertions.assertSame(first.get(0), second.get(0), + "Both listeners must receive the same event instance, so " + + "they see identical field and values"); } @Test @@ -1306,7 +1306,7 @@ void nullListenerThrows() { var controller = new FormAIController(new Div(new TestField())); Assertions.assertThrows(NullPointerException.class, - () -> controller.addFieldValueChangedListener(null)); + () -> controller.addFieldValueChangeListener(null)); } @Test @@ -1315,7 +1315,7 @@ void registrationRemoveStopsFutureCalls() { var controller = new FormAIController(new Div(field)); var calls = new AtomicInteger(); var registration = controller - .addFieldValueChangedListener(c -> calls.incrementAndGet()); + .addFieldValueChangeListener(e -> calls.incrementAndGet()); controller.onRequest(); field.setValue("first"); @@ -1340,11 +1340,11 @@ void listenerExceptionDoesNotPreventOtherListeners() { var field = new TestField(); var controller = new FormAIController(new Div(field)); var followingCalls = new AtomicInteger(); - controller.addFieldValueChangedListener(c -> { + controller.addFieldValueChangeListener(e -> { throw new RuntimeException("first throws"); }); - controller.addFieldValueChangedListener( - c -> followingCalls.incrementAndGet()); + controller.addFieldValueChangeListener( + e -> followingCalls.incrementAndGet()); controller.onRequest(); field.setValue("X"); @@ -1355,6 +1355,38 @@ void listenerExceptionDoesNotPreventOtherListeners() { + "next listener from firing"); } + @Test + void listenerExceptionDoesNotPreventSubsequentEventsInSameTurn() { + // When a listener throws on the first field's event, both that + // throwing listener and any other listener must still fire for + // the second field's event. Pin both halves so a regression + // that swallows further events for either party would surface. + var first = new TestField(); + var second = new TestField(); + var controller = new FormAIController(new Div(first, second)); + var throwerCalls = new AtomicInteger(); + var followerCalls = new AtomicInteger(); + controller.addFieldValueChangeListener(e -> { + throwerCalls.incrementAndGet(); + throw new RuntimeException("always throws"); + }); + controller.addFieldValueChangeListener( + e -> followerCalls.incrementAndGet()); + + controller.onRequest(); + first.setValue("a"); + second.setValue("b"); + controller.onResponse(null); + + Assertions.assertEquals(2, throwerCalls.get(), + "The throwing listener must still be invoked for every " + + "changed field, even after its own prior " + + "invocation threw"); + Assertions.assertEquals(2, followerCalls.get(), + "A non-throwing follower must also receive every event " + + "even when a prior listener throws on each"); + } + @Test void listenerCanRemoveItselfDuringDispatchWithoutBreakingTheTurn() { // Self-removal during dispatch must not throw or skip remaining @@ -1364,12 +1396,12 @@ void listenerCanRemoveItselfDuringDispatchWithoutBreakingTheTurn() { var registration = new AtomicReference(); var selfRemovingCalls = new AtomicInteger(); var followingCalls = new AtomicInteger(); - registration.set(controller.addFieldValueChangedListener(c -> { + registration.set(controller.addFieldValueChangeListener(e -> { selfRemovingCalls.incrementAndGet(); registration.get().remove(); })); - controller.addFieldValueChangedListener( - c -> followingCalls.incrementAndGet()); + controller.addFieldValueChangeListener( + e -> followingCalls.incrementAndGet()); controller.onRequest(); field.setValue("X"); @@ -1393,11 +1425,11 @@ void listenerCanRemoveItselfDuringDispatchWithoutBreakingTheTurn() { } @Test - void fieldRevealedMidTurnIsReportedAsChange() { + void fieldRevealedMidTurnProducesEvent() { // Visibility-cascade headline: a hidden field that gets - // revealed-and-written during a single turn must surface in - // the change list. Otherwise the LLM's effect on the form - // would be silently underreported. + // revealed-and-written during a single turn must surface as an + // event. Otherwise the LLM's effect on the form would be + // silently underreported. var primary = new TestField(); var conditional = new TestField(); conditional.setVisible(false); @@ -1407,23 +1439,23 @@ void fieldRevealedMidTurnIsReportedAsChange() { }); var controller = new FormAIController( new Div(primary, conditional)); - var captured = new AtomicReference>(); - controller.addFieldValueChangedListener(captured::set); + var events = new ArrayList(); + controller.addFieldValueChangeListener(events::add); controller.onRequest(); primary.setValue("driver"); controller.onResponse(null); - var change = changeFor(captured.get(), conditional); - Assertions.assertEquals("", change.oldValue(), + var event = eventFor(events, conditional); + Assertions.assertEquals("", event.getOldValue(), "Old value must reflect the field's pre-turn value"); - Assertions.assertEquals("derived", change.newValue()); + Assertions.assertEquals("derived", event.getNewValue()); } @Test void hiddenFieldRevealedAndChangedReportsRealOldValue() { // When the hidden field already had a non-null value - // (e.g. bound to a bean), the diff must report the real + // (e.g. bound to a bean), the event must report the real // (preset → derived) transition rather than (null → derived). var primary = new TestField(); var conditional = new TestField(); @@ -1435,25 +1467,24 @@ void hiddenFieldRevealedAndChangedReportsRealOldValue() { }); var controller = new FormAIController( new Div(primary, conditional)); - var captured = new AtomicReference>(); - controller.addFieldValueChangedListener(captured::set); + var events = new ArrayList(); + controller.addFieldValueChangeListener(events::add); controller.onRequest(); primary.setValue("driver"); controller.onResponse(null); - var change = changeFor(captured.get(), conditional); - Assertions.assertEquals("preset", change.oldValue(), + var event = eventFor(events, conditional); + Assertions.assertEquals("preset", event.getOldValue(), "Pre-turn value of a hidden field must round-trip into " - + "the change record, not a spurious null"); - Assertions.assertEquals("derived", change.newValue()); + + "the event, not a spurious null"); + Assertions.assertEquals("derived", event.getNewValue()); } @Test - void fieldRevealedMidTurnWithUnchangedValueIsNotReported() { + void fieldRevealedMidTurnWithUnchangedValueProducesNoEvent() { // False-positive guard: revealing a hidden field without - // writing to it is not a change and must not appear in the - // change list. + // writing to it is not a change and must not produce an event. var primary = new TestField(); var conditional = new TestField(); conditional.setValue("preset"); @@ -1461,21 +1492,20 @@ void fieldRevealedMidTurnWithUnchangedValueIsNotReported() { primary.addValueChangeListener(e -> conditional.setVisible(true)); var controller = new FormAIController( new Div(primary, conditional)); - var captured = new AtomicReference>(); - controller.addFieldValueChangedListener(captured::set); + var events = new ArrayList(); + controller.addFieldValueChangeListener(events::add); controller.onRequest(); primary.setValue("driver"); controller.onResponse(null); - Assertions.assertFalse( - containsChangeFor(captured.get(), conditional), + Assertions.assertFalse(containsEventFor(events, conditional), "Revealing a hidden field without changing its value " - + "must not be reported as a change"); + + "must not produce an event"); } @Test - void fieldRevealedAndFilledInSameTurnIsReported() { + void fieldRevealedAndFilledInSameTurnProducesEvent() { var controlling = new TestField(); var conditional = new TestField(); conditional.setVisible(false); @@ -1483,27 +1513,25 @@ void fieldRevealedAndFilledInSameTurnIsReported() { .addValueChangeListener(e -> conditional.setVisible(true)); var controller = new FormAIController( new Div(controlling, conditional)); - var captured = new AtomicReference>(); - controller.addFieldValueChangedListener(captured::set); + var events = new ArrayList(); + controller.addFieldValueChangeListener(events::add); controller.onRequest(); controlling.setValue("business"); // reveals the conditional field conditional.setValue("cost-center-42"); // AI fills the revealed one controller.onResponse(null); - var changes = captured.get(); - Assertions.assertTrue(containsChangeFor(changes, conditional), - "A field revealed and filled within the same turn must be " - + "reported as changed; got: " + changes); + Assertions.assertTrue(containsEventFor(events, conditional), + "A field revealed and filled within the same turn must " + + "produce an event; got: " + events); } @Test - void fieldAddedAndFilledInSameTurnIsReported() { - // Stronger variant: the conditional field does not exist in - // the form when onRequest snapshots. A controlling field's - // listener ADDS it to the form mid-turn (e.g. a checkbox - // revealing a new panel), and the same turn fills it. It must - // still be reported as changed. + void fieldAddedAndFilledInSameTurnProducesEvent() { + // A field that does not exist when onRequest snapshots but is + // added to the form mid-turn (e.g. by a controlling field + // revealing a new panel) and filled in the same turn must + // still produce an event. var controlling = new TestField(); var added = new TestField(); var form = new Div(controlling); @@ -1511,32 +1539,207 @@ void fieldAddedAndFilledInSameTurnIsReported() { // by the controlling field's value change. controlling.addValueChangeListener(e -> form.add(added)); var controller = new FormAIController(form); - var captured = new AtomicReference>(); - controller.addFieldValueChangedListener(captured::set); + var events = new ArrayList(); + controller.addFieldValueChangeListener(events::add); controller.onRequest(); controlling.setValue("business"); // adds the new field to the form added.setValue("cost-center-42"); // AI fills the newly-added field controller.onResponse(null); - var changes = captured.get(); - Assertions.assertTrue(containsChangeFor(changes, added), + Assertions.assertTrue(containsEventFor(events, added), "A field added to the form and filled within the same " - + "turn must be reported as changed; got: " - + changes); + + "turn must produce an event; got: " + events); + Assertions.assertEquals(added.getEmptyValue(), + eventFor(events, added).getOldValue(), + "A field with no pre-turn snapshot must report its empty " + + "value as the old value"); + } + + @Test + void fieldAddedMidTurnWithoutWriteProducesNoEvent() { + // False-positive guard: a field added to the form mid-turn that + // keeps its empty value was not changed by the turn and must not + // produce an event, even though it has no pre-turn snapshot. + var controlling = new TestField(); + var added = new TestField(); + var form = new Div(controlling); + controlling.addValueChangeListener(e -> form.add(added)); + var controller = new FormAIController(form); + var events = new ArrayList(); + controller.addFieldValueChangeListener(events::add); + + controller.onRequest(); + controlling.setValue("business"); // adds the new field, no write + controller.onResponse(null); + + Assertions.assertFalse(containsEventFor(events, added), + "A field added mid-turn but never written must not " + + "produce an event; got: " + events); + } + + @Test + void eventSourceIsTheControllerForEveryEventInATurn() { + // getSource() is the only way for a listener to recover the + // controller without capturing it. Pin that every event in a + // multi-event turn returns the same controller, not, say, null + // on the second event or a different instance. + var first = new TestField(); + var second = new TestField(); + var controller = new FormAIController(new Div(first, second)); + var events = new ArrayList(); + controller.addFieldValueChangeListener(events::add); + + controller.onRequest(); + first.setValue("a"); + second.setValue("b"); + controller.onResponse(null); + + Assertions.assertEquals(2, events.size(), + "Expected two events for two changed fields"); + for (var event : events) { + Assertions.assertSame(controller, event.getSource(), + "Every event must carry the same controller as its " + + "source; got: " + event.getSource()); + } + } + + @Test + void eventDispatchVisitsAllListenersBeforeMovingToNextEvent() { + // Every listener fires in registration order for event 1 + // before any listener fires for event 2. Pinning this lets + // listeners depend on each other's side effects within an + // event (e.g. listener A annotates the source, listener B + // reads the annotation) without leaking across events. + var first = new TestField(); + var second = new TestField(); + var controller = new FormAIController(new Div(first, second)); + var order = new ArrayList(); + controller.addFieldValueChangeListener( + e -> order.add("A:" + e.getNewValue())); + controller.addFieldValueChangeListener( + e -> order.add("B:" + e.getNewValue())); + + controller.onRequest(); + first.setValue("a"); + second.setValue("b"); + controller.onResponse(null); + + Assertions.assertEquals(List.of("A:a", "B:a", "A:b", "B:b"), order, + "Listeners must visit each event in registration order, " + + "then advance to the next event: got " + order); + } + + @Test + void writingToFieldInsideListenerDoesNotPoisonOtherEventsValues() { + // The diff is materialised before any listener runs, so a + // listener writing to a tracked field cannot retroactively + // alter another event's values. Otherwise events would carry + // values that don't match the LLM's effect — they'd carry + // whatever the listener happened to write. + var first = new TestField(); + var second = new TestField(); + var controller = new FormAIController(new Div(first, second)); + var events = new ArrayList(); + controller.addFieldValueChangeListener(e -> { + events.add(e); + if (e.getField() == first) { + second.setValue("listener-overwrote"); + } + }); + + controller.onRequest(); + first.setValue("from-llm"); + second.setValue("from-llm-too"); + controller.onResponse(null); + + var secondEvent = eventFor(events, second); + Assertions.assertEquals("", secondEvent.getOldValue(), + "The second event's oldValue must reflect the field's " + + "value at turn start, not anything the listener " + + "wrote during the turn"); + Assertions.assertEquals("from-llm-too", secondEvent.getNewValue(), + "The second event's newValue must reflect the LLM's " + + "write, not the side effect of the listener " + + "that ran for the first event"); + } + + @Test + void listenerAddedDuringDispatchDoesNotReceiveCurrentTurnEvents() { + // Snapshot-per-turn semantics: a listener that registers itself + // from inside another listener's handler must not start + // receiving events until the NEXT turn. Otherwise a one-shot + // self-registering listener pattern would observe a partial + // view of the current turn's changes. + var first = new TestField(); + var second = new TestField(); + var controller = new FormAIController(new Div(first, second)); + var lateCalls = new AtomicInteger(); + controller.addFieldValueChangeListener(e -> { + if (e.getField() == first) { + controller.addFieldValueChangeListener( + ev -> lateCalls.incrementAndGet()); + } + }); + + controller.onRequest(); + first.setValue("a"); + second.setValue("b"); + controller.onResponse(null); + + Assertions.assertEquals(0, lateCalls.get(), + "Listener registered mid-dispatch must not receive any " + + "of the current turn's remaining events"); + + controller.onRequest(); + first.setValue("c"); + controller.onResponse(null); + + Assertions.assertEquals(1, lateCalls.get(), + "Listener registered in the previous turn must receive " + + "events from subsequent turns"); + } + + @Test + void removedListenerStillReceivesRestOfCurrentTurnEvents() { + // Snapshot-per-turn semantics, opposite direction: a listener + // that removes itself from inside its own handler must still + // receive the rest of the current turn's events. The cleanup + // takes effect only on the next turn. + var first = new TestField(); + var second = new TestField(); + var controller = new FormAIController(new Div(first, second)); + var calls = new ArrayList>(); + var registration = new AtomicReference(); + registration.set(controller.addFieldValueChangeListener(e -> { + calls.add(e.getField()); + if (e.getField() == first) { + registration.get().remove(); + } + })); + + controller.onRequest(); + first.setValue("a"); + second.setValue("b"); + controller.onResponse(null); + + Assertions.assertEquals(List.of(first, second), calls, + "A listener that removes itself during event 1 must " + + "still receive event 2 of the same turn"); } - private static FieldValueChange changeFor( - List changes, HasValue field) { - return changes.stream().filter(c -> c.field() == field).findFirst() + private static FieldValueChangeEvent eventFor( + List events, HasValue field) { + return events.stream().filter(e -> e.getField() == field) + .findFirst() .orElseThrow(() -> new AssertionError( - "No FieldValueChange entry for field " + field - + " in " + changes)); + "No FieldValueChangeEvent for field " + field + + " in " + events)); } - private static boolean containsChangeFor(List changes, - HasValue field) { - return changes.stream().anyMatch(c -> c.field() == field); + private static boolean containsEventFor( + List events, HasValue field) { + return events.stream().anyMatch(e -> e.getField() == field); } }