Discover Model paths and compatible binding targets in both hosts

Add non-constructing target metadata and a shared read-only browser for the Person fixtures. Negotiate binding discovery independently, preserve FXML, and explain direction/type mismatches. Record remaining manual navigation checks as explicitly skipped.
This commit is contained in:
2026-11-13 08:57:35 +01:00
parent 2156894fa8
commit 2fe408bb4d
38 changed files with 1087 additions and 37 deletions
@@ -0,0 +1,98 @@
package de.neitzel.fxdesigner.protocol;
import java.util.*;
/** Non-constructing target metadata; compatibility is computed by the target type system. */
public record BindingMetadata(
String model,
String path,
String direction,
List<Attribute> attributes,
List<Target> targets,
List<String> diagnostics) {
public record Attribute(String path, String type, String kind, String elementType) {
public String toString() {
return path + " : " + type + " (" + kind + ")";
}
}
public record Target(
String control, String property, String type, String status, String reason) {}
public BindingMetadata {
attributes = List.copyOf(attributes);
targets = List.copyOf(targets);
diagnostics = List.copyOf(diagnostics);
if (!Set.of("view", "model", "both").contains(direction))
throw new IllegalArgumentException("Invalid direction");
}
public Map<String, String> encode() {
var m = new TreeMap<String, String>();
m.put("format", "1");
m.put("model", model);
m.put("path", path);
m.put("direction", direction);
m.put("attributes", "" + attributes.size());
m.put("targets", "" + targets.size());
m.put("diagnostics", "" + diagnostics.size());
for (int i = 0; i < attributes.size(); i++) {
var a = attributes.get(i);
String p = "a." + i + ".";
m.put(p + "path", a.path);
m.put(p + "type", a.type);
m.put(p + "kind", a.kind);
m.put(p + "element", a.elementType);
}
for (int i = 0; i < targets.size(); i++) {
var t = targets.get(i);
String p = "t." + i + ".";
m.put(p + "control", t.control);
m.put(p + "property", t.property);
m.put(p + "type", t.type);
m.put(p + "status", t.status);
m.put(p + "reason", t.reason);
}
for (int i = 0; i < diagnostics.size(); i++) m.put("d." + i, diagnostics.get(i));
return m;
}
private static String field(Map<String, String> m, String key) {
return Objects.requireNonNull(m.get(key), "Missing " + key);
}
private static int count(Map<String, String> m, String key) {
int n = Integer.parseInt(field(m, key));
if (n < 0 || n > 2048) throw new IllegalArgumentException("Invalid metadata count");
return n;
}
public static BindingMetadata decode(Map<String, String> m) {
if (!"1".equals(field(m, "format")))
throw new IllegalArgumentException("Unsupported binding metadata");
var a = new ArrayList<Attribute>();
var t = new ArrayList<Target>();
var d = new ArrayList<String>();
for (int i = 0; i < count(m, "attributes"); i++) {
String p = "a." + i + ".";
a.add(
new Attribute(
field(m, p + "path"),
field(m, p + "type"),
field(m, p + "kind"),
field(m, p + "element")));
}
for (int i = 0; i < count(m, "targets"); i++) {
String p = "t." + i + ".";
t.add(
new Target(
field(m, p + "control"),
field(m, p + "property"),
field(m, p + "type"),
field(m, p + "status"),
field(m, p + "reason")));
}
for (int i = 0; i < count(m, "diagnostics"); i++) d.add(field(m, "d." + i));
return new BindingMetadata(field(m, "model"), field(m, "path"), field(m, "direction"), a, t, d);
}
}
@@ -81,6 +81,9 @@ public final class ProtocolSession implements AutoCloseable {
if (hello.preview().major() == 1
&& supported.contains("interactive-v1")
&& hello.capabilities().contains("interactive-v1")) usable.add(Operation.INTERACTIVE);
if (hello.metadata().major() == 1
&& supported.contains("bindings-v1")
&& hello.capabilities().contains("bindings-v1")) usable.add(Operation.BINDINGS);
var requiredByEither = new HashSet<>(required);
requiredByEither.addAll(hello.required());
if ((requiredByEither.contains("discover-v1") && !usable.contains(Operation.DISCOVER))
@@ -89,6 +92,8 @@ public final class ProtocolSession implements AutoCloseable {
if ((requiredByEither.contains("layout-v1") && !usable.contains(Operation.MEASURE))
|| (requiredByEither.contains("interactive-v1") && !usable.contains(Operation.INTERACTIVE)))
throw new IllegalArgumentException("Required capability version incompatible");
if (requiredByEither.contains("bindings-v1") && !usable.contains(Operation.BINDINGS))
throw new IllegalArgumentException("Required capability version incompatible");
operations = Set.copyOf(usable);
state = State.READY;
return operations;
@@ -76,7 +76,8 @@ public final class WireProtocol {
RENDER,
MEASURE,
OCCURRENCES,
INTERACTIVE
INTERACTIVE,
BINDINGS
}
public record Identity(
@@ -114,11 +115,15 @@ public final class WireProtocol {
Objects.requireNonNull(operation);
Objects.requireNonNull(identity);
Objects.requireNonNull(payload);
boolean render = operation != Operation.DISCOVER;
boolean render = operation != Operation.DISCOVER && operation != Operation.BINDINGS;
if (render
&& (identity.width() == 0 || identity.height() == 0 || identity.revisions().isEmpty()))
throw new IllegalArgumentException("Rendering needs dimensions and document revisions");
if (!render
if (operation == Operation.BINDINGS
&& (identity.width() != 0 || identity.height() != 0 || identity.revisions().isEmpty()))
throw new IllegalArgumentException(
"Binding discovery needs document revisions and no dimensions");
if (operation == Operation.DISCOVER
&& (identity.width() != 0 || identity.height() != 0 || !identity.revisions().isEmpty()))
throw new IllegalArgumentException("Discovery does not carry render identity");
boolean image = operation == Operation.RENDER && kind == Kind.RESULT;
@@ -0,0 +1,76 @@
package de.neitzel.fxdesigner.protocol;
import static de.neitzel.fxdesigner.protocol.WireProtocol.*;
import static org.junit.jupiter.api.Assertions.*;
import java.net.*;
import java.util.*;
import org.junit.jupiter.api.Test;
class BindingMetadataTest {
@Test
void metadataRoundTripAndIdentityAreBounded() throws Exception {
var m =
new BindingMetadata(
"Person",
"name",
"both",
List.of(new BindingMetadata.Attribute("name", "java.lang.String", "VALUE", "")),
List.of(
new BindingMetadata.Target(
"input", "text", "java.lang.String", "COMPATIBLE", "Types match")),
List.of());
assertEquals(
m, BindingMetadata.decode(PreviewMessages.decode(PreviewMessages.encode(m.encode()))));
var id = new Identity("s", "r", "c", "b", Map.of("file", 1L), 0, 0);
assertDoesNotThrow(
() -> new Packet(Kind.RESULT, Operation.BINDINGS, id, PreviewMessages.encode(m.encode())));
assertThrows(
IllegalArgumentException.class,
() ->
new Packet(
Kind.REQUEST,
Operation.BINDINGS,
new Identity("s", "r", "c", "b", Map.of(), 0, 0),
new byte[0]));
var invalid = new HashMap<>(m.encode());
invalid.put("targets", "99999");
assertThrows(IllegalArgumentException.class, () -> BindingMetadata.decode(invalid));
}
@Test
void negotiationRequiresMetadataVersion() throws Exception {
String token = ProtocolSession.newToken();
var session = new ProtocolSession("s", 1, token, Set.of("bindings-v1"), Set.of("bindings-v1"));
assertTrue(
session
.authenticate(
InetAddress.getLoopbackAddress(),
new Hello(
"s",
1,
token,
new Version(1, 0),
new Version(1, 0),
new Version(1, 0),
Set.of("bindings-v1"),
Set.of()))
.contains(Operation.BINDINGS));
var incompatible =
new ProtocolSession("s", 1, token, Set.of("bindings-v1"), Set.of("bindings-v1"));
assertThrows(
IllegalArgumentException.class,
() ->
incompatible.authenticate(
InetAddress.getLoopbackAddress(),
new Hello(
"s",
1,
token,
new Version(1, 0),
new Version(2, 0),
new Version(1, 0),
Set.of("bindings-v1"),
Set.of())));
}
}