diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/map/EffectiveTrafficMap.java b/javaTest/src/main/java/com/ft546/wcs/shadow/map/EffectiveTrafficMap.java new file mode 100644 index 0000000..9566703 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/map/EffectiveTrafficMap.java @@ -0,0 +1,45 @@ +package com.ft546.wcs.shadow.map; + +import com.ft546.wcs.shadow.resource.ResourceKey; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +public final class EffectiveTrafficMap { + private final String mapCode; + private final long versionId; + private final String zoneCode; + private final String checksum; + private final Set nodes; + private final Set edges; + private final Map capacities; + private final Set safeWaitSites; + private final Set safeYieldSites; + + EffectiveTrafficMap(String mapCode, long versionId, String zoneCode, String checksum, + Set nodes, Set edges, Map capacities, + Set safeWaitSites, Set safeYieldSites) { + this.mapCode = mapCode; + this.versionId = versionId; + this.zoneCode = zoneCode; + this.checksum = checksum; + this.nodes = Collections.unmodifiableSet(new LinkedHashSet(nodes)); + this.edges = Collections.unmodifiableSet(new LinkedHashSet(edges)); + this.capacities = Collections.unmodifiableMap(new LinkedHashMap(capacities)); + this.safeWaitSites = Collections.unmodifiableSet(new LinkedHashSet(safeWaitSites)); + this.safeYieldSites = Collections.unmodifiableSet(new LinkedHashSet(safeYieldSites)); + } + + public String getMapCode() { return mapCode; } + public long getVersionId() { return versionId; } + public String getZoneCode() { return zoneCode; } + public String getChecksum() { return checksum; } + public Set getNodes() { return nodes; } + public Set getEdges() { return edges; } + public Map getCapacities() { return capacities; } + public Set getSafeWaitSites() { return safeWaitSites; } + public Set getSafeYieldSites() { return safeYieldSites; } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/map/TrafficMapCompiler.java b/javaTest/src/main/java/com/ft546/wcs/shadow/map/TrafficMapCompiler.java new file mode 100644 index 0000000..53cf3f8 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/map/TrafficMapCompiler.java @@ -0,0 +1,94 @@ +package com.ft546.wcs.shadow.map; + +import com.ft546.wcs.shadow.resource.ResourceKey; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +public final class TrafficMapCompiler { + public EffectiveTrafficMap compile(MapDefinition definition) { + if (definition == null) { + throw new IllegalArgumentException("map definition is required"); + } + if (!definition.confirmed) { + throw new IllegalArgumentException("traffic zone is not confirmed"); + } + if (!definition.baseChecksum.equals(definition.configuredChecksum)) { + throw new IllegalArgumentException("base map checksum mismatch"); + } + LinkedHashSet edges = new LinkedHashSet(); + LinkedHashMap capacities = new LinkedHashMap(); + for (String node : definition.nodes) { + capacities.put(ResourceKey.node(node), 1); + } + for (EdgeDefinition edge : definition.edges) { + if (!definition.nodes.contains(edge.first) || !definition.nodes.contains(edge.second)) { + throw new IllegalArgumentException("edge references missing node"); + } + ResourceKey key = ResourceKey.edge(edge.first, edge.second); + edges.add(key); + capacities.put(key, 1); + } + for (Map.Entry entry : definition.explicitCapacities.entrySet()) { + if (entry.getValue() == null || entry.getValue() <= 0) { + throw new IllegalArgumentException("resource capacity must be positive"); + } + capacities.put(entry.getKey(), entry.getValue()); + } + return new EffectiveTrafficMap(definition.mapCode, definition.versionId, definition.zoneCode, + definition.baseChecksum, definition.nodes, edges, capacities, + definition.safeWaitSites, definition.safeYieldSites); + } + + public static final class EdgeDefinition { + private final String first; + private final String second; + + public EdgeDefinition(String first, String second) { + this.first = first; + this.second = second; + } + } + + public static final class MapDefinition { + private final String mapCode; + private final long versionId; + private final String zoneCode; + private final boolean confirmed; + private final String baseChecksum; + private final String configuredChecksum; + private final Set nodes; + private final Set edges; + private final Map explicitCapacities; + private final Set safeWaitSites; + private final Set safeYieldSites; + + public MapDefinition(String mapCode, long versionId, String zoneCode, boolean confirmed, + String baseChecksum, String configuredChecksum, Set nodes, + Set edges, Map explicitCapacities, + Set safeWaitSites, Set safeYieldSites) { + if (mapCode == null || zoneCode == null || baseChecksum == null || configuredChecksum == null) { + throw new IllegalArgumentException("map identity is required"); + } + this.mapCode = mapCode; + this.versionId = versionId; + this.zoneCode = zoneCode; + this.confirmed = confirmed; + this.baseChecksum = baseChecksum; + this.configuredChecksum = configuredChecksum; + this.nodes = immutable(nodes); + this.edges = immutable(edges); + this.explicitCapacities = Collections.unmodifiableMap( + new LinkedHashMap(explicitCapacities)); + this.safeWaitSites = immutable(safeWaitSites); + this.safeYieldSites = immutable(safeYieldSites); + } + + private static Set immutable(Set source) { + return Collections.unmodifiableSet(new LinkedHashSet(source)); + } + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/resource/ResourceKey.java b/javaTest/src/main/java/com/ft546/wcs/shadow/resource/ResourceKey.java new file mode 100644 index 0000000..b667b6f --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/resource/ResourceKey.java @@ -0,0 +1,56 @@ +package com.ft546.wcs.shadow.resource; + +import java.util.Objects; + +public final class ResourceKey implements Comparable { + private final String value; + + private ResourceKey(String value) { + this.value = value; + } + + public static ResourceKey node(String siteId) { + return new ResourceKey("NODE:" + required(siteId)); + } + + public static ResourceKey edge(String first, String second) { + String a = required(first); + String b = required(second); + return new ResourceKey(a.compareTo(b) <= 0 ? "EDGE:" + a + ":" + b : "EDGE:" + b + ":" + a); + } + + public static ResourceKey conflict(String code) { + return new ResourceKey("CONFLICT:" + required(code)); + } + + public String value() { + return value; + } + + private static String required(String value) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException("resource component is required"); + } + return value.trim(); + } + + @Override + public int compareTo(ResourceKey other) { + return value.compareTo(other.value); + } + + @Override + public boolean equals(Object other) { + return other instanceof ResourceKey && value.equals(((ResourceKey) other).value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return value; + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/resource/ResourceLedger.java b/javaTest/src/main/java/com/ft546/wcs/shadow/resource/ResourceLedger.java new file mode 100644 index 0000000..6da73cd --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/resource/ResourceLedger.java @@ -0,0 +1,100 @@ +package com.ft546.wcs.shadow.resource; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +public final class ResourceLedger { + private final Map capacities; + private final Map> holders = + new LinkedHashMap>(); + + public ResourceLedger(Map capacities) { + this.capacities = Collections.unmodifiableMap(new LinkedHashMap(capacities)); + } + + public ProtectionResult evaluate(String vehicleId, Collection requested) { + LinkedHashSet conflicts = new LinkedHashSet(); + LinkedHashSet blockers = new LinkedHashSet(); + for (ResourceKey key : requested) { + int capacity = capacity(key); + Set current = holders.containsKey(key) ? holders.get(key) : Collections.emptySet(); + int occupiedByOthers = current.contains(vehicleId) ? current.size() - 1 : current.size(); + if (occupiedByOthers >= capacity) { + conflicts.add(key); + for (String holder : current) { + if (!holder.equals(vehicleId)) { + blockers.add(holder); + } + } + } + } + if (!conflicts.isEmpty()) { + return new ProtectionResult(false, conflicts, blockers); + } + for (ResourceKey key : requested) { + if (!holders.containsKey(key)) { + holders.put(key, new LinkedHashSet()); + } + holders.get(key).add(vehicleId); + } + return new ProtectionResult(true, Collections.emptySet(), Collections.emptySet()); + } + + public void release(String vehicleId, Collection resources) { + for (ResourceKey key : resources) { + Set current = holders.get(key); + if (current != null) { + current.remove(vehicleId); + } + } + } + + public Set holdersFor(ResourceKey key) { + Set current = holders.get(key); + return current == null ? Collections.emptySet() + : Collections.unmodifiableSet(new LinkedHashSet(current)); + } + + public int protectedCount(ResourceKey key) { + return holdersFor(key).size(); + } + + public ResourceLedger copy() { + ResourceLedger copy = new ResourceLedger(capacities); + for (Map.Entry> entry : holders.entrySet()) { + copy.holders.put(entry.getKey(), new LinkedHashSet(entry.getValue())); + } + return copy; + } + + private int capacity(ResourceKey key) { + Integer capacity = capacities.get(key); + if (capacity == null) { + throw new IllegalArgumentException("unknown resource " + key); + } + return capacity; + } + + public static final class ProtectionResult { + private final boolean granted; + private final Set conflictingResources; + private final Set blockingVehicles; + + private ProtectionResult(boolean granted, Set conflictingResources, + Set blockingVehicles) { + this.granted = granted; + this.conflictingResources = Collections.unmodifiableSet( + new LinkedHashSet(conflictingResources)); + this.blockingVehicles = Collections.unmodifiableSet( + new LinkedHashSet(blockingVehicles)); + } + + public boolean isGranted() { return granted; } + public Set getConflictingResources() { return conflictingResources; } + public Set getBlockingVehicles() { return blockingVehicles; } + } +} diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/MapAndResourceLedgerTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/MapAndResourceLedgerTest.java new file mode 100644 index 0000000..92b3203 --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/MapAndResourceLedgerTest.java @@ -0,0 +1,77 @@ +package com.ft546.wcs.shadow; + +import com.ft546.wcs.shadow.map.EffectiveTrafficMap; +import com.ft546.wcs.shadow.map.TrafficMapCompiler; +import com.ft546.wcs.shadow.resource.ResourceKey; +import com.ft546.wcs.shadow.resource.ResourceLedger; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MapAndResourceLedgerTest { + + @Test + void canonicalizesUndirectedEdgesAndDoesNotLockWholeZone() { + assertEquals("EDGE:A:B", ResourceKey.edge("B", "A").value()); + + EffectiveTrafficMap map = new TrafficMapCompiler().compile(definition("sha-546", "sha-546", true)); + ResourceLedger ledger = new ResourceLedger(map.getCapacities()); + + assertTrue(ledger.evaluate("V1", Collections.singleton(ResourceKey.node("A"))).isGranted()); + assertTrue(ledger.evaluate("V2", Collections.singleton(ResourceKey.node("C"))).isGranted()); + } + + @Test + void rejectsUnconfirmedOrMismatchedMap() { + TrafficMapCompiler compiler = new TrafficMapCompiler(); + + assertThrows(IllegalArgumentException.class, + () -> compiler.compile(definition("sha-546", "wrong", true))); + assertThrows(IllegalArgumentException.class, + () -> compiler.compile(definition("sha-546", "sha-546", false))); + } + + @Test + void enforcesNodeEdgeAndConfiguredCapacity() { + EffectiveTrafficMap map = new TrafficMapCompiler().compile(definition("sha-546", "sha-546", true)); + ResourceLedger ledger = new ResourceLedger(map.getCapacities()); + + assertTrue(ledger.evaluate("V1", Arrays.asList(ResourceKey.node("B"), ResourceKey.edge("A", "B"))).isGranted()); + ResourceLedger.ProtectionResult nodeConflict = + ledger.evaluate("V2", Collections.singleton(ResourceKey.node("B"))); + ResourceLedger.ProtectionResult oppositeEdge = + ledger.evaluate("V3", Collections.singleton(ResourceKey.edge("B", "A"))); + + assertFalse(nodeConflict.isGranted()); + assertEquals(Collections.singleton("V1"), nodeConflict.getBlockingVehicles()); + assertFalse(oppositeEdge.isGranted()); + + assertTrue(ledger.evaluate("V4", Collections.singleton(ResourceKey.conflict("WIDE"))).isGranted()); + assertTrue(ledger.evaluate("V5", Collections.singleton(ResourceKey.conflict("WIDE"))).isGranted()); + assertFalse(ledger.evaluate("V6", Collections.singleton(ResourceKey.conflict("WIDE"))).isGranted()); + } + + private TrafficMapCompiler.MapDefinition definition( + String baseChecksum, String configuredChecksum, boolean confirmed) { + LinkedHashSet nodes = new LinkedHashSet(Arrays.asList("A", "B", "C")); + LinkedHashSet edges = + new LinkedHashSet(); + edges.add(new TrafficMapCompiler.EdgeDefinition("A", "B")); + edges.add(new TrafficMapCompiler.EdgeDefinition("B", "C")); + Map capacities = new LinkedHashMap(); + capacities.put(ResourceKey.conflict("WIDE"), 2); + return new TrafficMapCompiler.MapDefinition( + "FT546", 2L, "ZONE-1", confirmed, baseChecksum, configuredChecksum, + nodes, edges, capacities, + Collections.singleton("A"), Collections.singleton("C")); + } +}