fix: repair shadow candidate planning and site runtime errors

This commit is contained in:
zgw
2026-09-09 09:57:28 +08:00
parent 1a34d72cbb
commit 0bd47e2b2c
27 changed files with 788 additions and 203 deletions

View File

@@ -0,0 +1,98 @@
package com.ruoyi.Dzp;
import com.ruoyi.Dzp.domain.basLed;
import com.ruoyi.Dzp.mapper.BasLedMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class DzpSendTest {
@Test
void failedExchangeClosesItsSocketAndDoesNotAcknowledgeTheDisplay() throws Exception {
var socket = mock(MyUdpSocket.class);
var sender = new DzpSend() { @Override MyUdpSocket openSocket() { return socket; } };
var mapper = mock(BasLedMapper.class);
ReflectionTestUtils.setField(sender, "mapper", mapper);
var record = new basLed(); record.setModel("3"); record.setSendFlag("0"); record.setIpAddress("127.0.0.1"); record.setErrorMsg("alarm");
when(mapper.selectBasLedList(any())).thenAnswer(call -> call.getArgument(0, basLed.class).getId() == 1 ? List.of(record) : List.of());
try (var protocol = mockStatic(ProtocolDemo.class)) {
protocol.when(() -> ProtocolDemo.demo_play_chapterschedule2(socket, "127.0.0.1", "报警信息", "alarm"))
.thenThrow(new IllegalStateException("test send failure")).thenReturn(true);
assertThatThrownBy(sender::ErrorLed).hasMessage("test send failure");
verify(socket).close();
verify(mapper, never()).updateBasLed(any());
assertThat(record.getSendFlag()).isEqualTo("0");
sender.ErrorLed();
verify(socket, times(2)).close();
verify(mapper).updateBasLed(record);
}
}
@Test
void busyLocalPortDefersOtherTimerWithoutBlockingOrChangingRows() throws Exception {
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
var sender = new DzpSend() {
@Override MyUdpSocket openSocket() throws IOException {
entered.countDown();
try { if (!release.await(3, TimeUnit.SECONDS)) throw new IOException("test timeout"); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException(e); }
throw new IOException("fixture port failure");
}
};
var mapper = mock(BasLedMapper.class);
ReflectionTestUtils.setField(sender, "mapper", mapper);
var record = new basLed(); record.setModel("1"); record.setSendFlag("0");
when(mapper.selectBasLedList(any())).thenReturn(List.of(record));
var executor = Executors.newFixedThreadPool(2);
try {
var first = executor.submit(() -> { assertThatThrownBy(sender::InLed).isInstanceOf(IOException.class); });
assertThat(entered.await(2, TimeUnit.SECONDS)).isTrue();
executor.submit(() -> { sender.ErrorLed(); return null; }).get(1, TimeUnit.SECONDS);
verify(mapper, times(1)).selectBasLedList(any());
verify(mapper, never()).updateBasLed(any());
release.countDown();
first.get(2, TimeUnit.SECONDS);
} finally { release.countDown(); executor.shutdownNow(); }
}
@Test
void eachScheduledMessageBuildsItsOwnPayload() throws Exception {
try (var payloads = mockConstruction(LEDSender2010.class,
(sender, context) -> when(sender.GetDataSize()).thenReturn((1 << 21) + 1))) {
var socket = mock(MyUdpSocket.class);
assertThat(ProtocolDemo.demo_play_chapterschedule2(socket, "127.0.0.1", "title1", "one")).isFalse();
assertThat(ProtocolDemo.demo_play_chapterschedule2(socket, "127.0.0.1", "title2", "two")).isFalse();
assertThat(payloads.constructed()).hasSize(2);
verify(payloads.constructed().get(0)).GetDataSize();
verify(payloads.constructed().get(1)).GetDataSize();
verifyNoInteractions(socket);
}
}
@Test
void transientSocketErrorsStayInsideTheBoundedProtocolRetry() throws Exception {
var socket = mock(MyUdpSocket.class);
var payload = mock(LEDSender2010.class);
doThrow(new IOException("transient send")).doNothing().when(socket).send(anyString(), anyInt(), any(), anyInt());
when(socket.receive()).thenThrow(new java.io.UncheckedIOException(new IOException("transient receive"))).thenReturn(1);
when(payload.parse_trans_respond(any(), anyInt(), anyInt(), anyInt())).thenReturn(1);
assertThat(ProtocolDemo.send_data1(socket, payload, "127.0.0.1")).isTrue();
verify(socket, times(3)).send(anyString(), anyInt(), any(), anyInt());
verify(socket, times(3)).receive();
}
@Test
void persistentSendFailureStopsAfterFiveAttempts() throws Exception {
var socket = mock(MyUdpSocket.class);
doThrow(new IOException("unreachable")).when(socket).send(anyString(), anyInt(), any(), anyInt());
assertThat(ProtocolDemo.send_data1(socket, mock(LEDSender2010.class), "127.0.0.1")).isFalse();
verify(socket, times(5)).send(anyString(), anyInt(), any(), anyInt());
verify(socket, never()).receive();
}
}

View File

@@ -0,0 +1,50 @@
package com.ruoyi.Dzp;
import org.junit.jupiter.api.Test;
import java.net.DatagramSocket;
import java.net.DatagramPacket;
import java.net.InetAddress;
import java.io.IOException;
import static org.assertj.core.api.Assertions.*;
class MyUdpSocketTest {
@Test
void occupiedPortFailsConstructionInsteadOfReturningBrokenSocket() throws Exception {
try (var occupied = new DatagramSocket(0)) {
assertThatThrownBy(() -> new MyUdpSocket(occupied.getLocalPort()))
.isInstanceOf(IOException.class);
}
}
@Test
void receivedPeerBelongsToItsOwnSocket() throws Exception {
MyUdpSocket first = new MyUdpSocket(0);
MyUdpSocket second = new MyUdpSocket(0);
try (var peerOne = new DatagramSocket(0); var peerTwo = new DatagramSocket(0)) {
exchange(first, peerOne, (byte) 11);
int firstPeer = first.get_client_port();
exchange(second, peerTwo, (byte) 22);
assertThat(first.get_client_port()).isEqualTo(firstPeer);
assertThat(second.get_client_port()).isEqualTo(peerTwo.getLocalPort());
first.close();
exchange(second, peerTwo, (byte) 33);
} finally {
first.close();
second.close();
}
}
private void exchange(MyUdpSocket socket, DatagramSocket peer, byte value) throws Exception {
peer.setSoTimeout(1000);
socket.send("127.0.0.1", peer.getLocalPort(), new byte[]{value}, 1);
var request = new DatagramPacket(new byte[64], 64);
peer.receive(request);
peer.send(new DatagramPacket(new byte[]{value}, 1, InetAddress.getLoopbackAddress(), request.getPort()));
int size = 0;
for (int attempt = 0; attempt < 100 && size == 0; attempt++) size = socket.receive();
assertThat(size).isEqualTo(1);
byte[] result = new byte[1];
socket.get_receive_packet(result, 1);
assertThat(result).containsExactly(value);
}
}

View File

@@ -172,6 +172,23 @@ class ShadowInboundEligibilityTest {
}
}
@Test
void multipleProductsWithOneFieldGroupCanContinueTheMatchingProjectLane() {
jdbc.update("update alg.shadow_inbound_request_snapshot set detail_count=5,product_scope_count=5,product_id=null,lot_no=null");
space("RESIDENT", "LANE", "1", "2", "N", "OLD", "C");
stock("RESIDENT", "OLD");
assertThat(eligible()).isEqualTo(1);
}
@Test
void ambiguousFieldGroupsOnlyAllowEntirelyEmptyLanes() {
jdbc.update("update alg.shadow_inbound_request_snapshot set detail_count=5,field_group_count=2,product_scope_count=5");
assertThat(eligible()).isEqualTo(1);
space("RESIDENT", "LANE", "1", "2", "N", "OLD", "C");
stock("RESIDENT", "OLD");
assertThat(eligible()).isZero();
}
private void space(String id, String lane, String floor, String status, String locked, String tray, String area) {
jdbc.update("insert into alg.cargo_space_snapshot values ('01',42,?,?,?,?,?,?,?,'UNRELATED')",
id, lane, floor, status, locked, tray, area);

View File

@@ -61,6 +61,7 @@ class ShadowInboundValidationTest {
reference.setTrayJobCount(1);
reference.setLegacyScopeComplete(1);
when(mapper.selectInboundInput("01", 42, "31331", "TRAY-1")).thenReturn(reference);
when(mapper.selectInboundEligibleSpaces("01", 42, "31331", "TRAY-1")).thenReturn(java.util.List.of("SPACE-9"));
when(mapper.countInboundEligibleSpace("01", 42, "31331", "TRAY-1", "SPACE-9")).thenReturn(1);
var assignment = new InboundAssignment("HPR-1", "SPACE-9", "LANE", 1, 1,
InboundLadderRung.CONTINUE, InboundBorrowType.NONE, null,
@@ -87,13 +88,22 @@ class ShadowInboundValidationTest {
reference.setDetailCount(45);
reference.setFieldGroupCount(2);
reference.setProductScopeCount(45);
assertSkipped("SKIPPED_AMBIGUOUS_LEGACY_FIELDS");
var result = plan();
assertThat(result.resultCode()).isEqualTo("READY_CONSERVATIVE");
assertThat(result.reason()).contains("legacyEligibility=EMPTY_LANE_ONLY");
engine.compareInbound(result, "10", "SPACE-9");
verify(mapper).insertComparison(any(), any(), eq("INBOUND_COMPARE"), anyLong(), any(), any(),
eq("MATCH_CONSERVATIVE"), any());
}
@Test
void multiProductTrayDoesNotSilentlyUseOneRepresentativeProduct() {
reference.setProductScopeCount(2);
assertSkipped("SKIPPED_MULTI_PRODUCT_INPUT");
assertThat(plan().resultCode()).isEqualTo("READY");
verify(facade).planInboundFromPublishedSnapshot(eq("01"), argThat(request ->
request.productId() == null && request.lotNo() == null
&& request.batchLaneMode() == com.ruoyi.algorithm.inbound.BatchLaneMode.OPEN
&& request.eligibleSpaceIds().equals(java.util.Set.of("SPACE-9"))));
}
@Test
@@ -148,6 +158,12 @@ class ShadowInboundValidationTest {
private InboundShadowObservation plan() { return engine.planInbound(snapshot, "31331", "TRAY-1"); }
@Test
void noConservativeSpaceDoesNotInventAnUnrestrictedRequest() {
when(mapper.selectInboundEligibleSpaces("01", 42, "31331", "TRAY-1")).thenReturn(java.util.List.of());
assertSkipped("SKIPPED_NO_CONSERVATIVE_SPACE");
}
private void assertSkipped(String code) {
var result = plan();
assertThat(result.resultCode()).isEqualTo(code);

View File

@@ -59,7 +59,7 @@ class ShadowOutboundValidationTest {
candidate.setTrayJobCount(0);
when(mapper.selectProjectOutboundReference("01", 459L, "DETAIL")).thenReturn(input);
when(mapper.selectProjectOutboundCandidates("01", 459L, "DETAIL")).thenReturn(List.of(candidate));
when(facade.planOutboundFromPublishedSnapshot(eq("01"), any())).thenReturn(
when(facade.planShadowOutboundCandidates(eq("01"), any(), any())).thenReturn(
OutboundPlan.ready(new OutboundAssignment("DETAIL", "TRAY", input.getProjectNo(), "PRODUCT",
"SPACE", 1, false, List.of(), List.of(), false, 0, 0, 0,
FifoDecision.off(), Optional.empty(), "proposal"), TaskCountEstimate.zero()));
@@ -71,7 +71,7 @@ class ShadowOutboundValidationTest {
assertThat(result.resultCode()).isEqualTo("READY");
assertThat(result.proposedStockId()).isEqualTo("STOCK");
assertThat(result.reason()).contains("predicateVersion=1", "requestedQuantity=3", "selectedProject=AD.L-00033.01.01");
verify(facade).planOutboundFromPublishedSnapshot(eq("01"), argThat(r -> !r.wholeTrayOrDefault()));
verify(facade).planShadowOutboundCandidates(eq("01"), argThat(r -> !r.wholeTrayOrDefault()), eq(java.util.Set.of("STOCK")));
verify(mapper, never()).refreshSnapshot(anyString(), anyLong(), anyString());
verify(facade, never()).planOutbound(anyString(), any());
}
@@ -80,7 +80,7 @@ class ShadowOutboundValidationTest {
void emptyProjectDerivesOnlyProvenCandidateScope() {
input.setProjectNo(null);
assertThat(plan().resultCode()).isEqualTo("READY");
verify(facade).planOutboundFromPublishedSnapshot(eq("01"), argThat(r -> "AD.L-00033.01.01".equals(r.projectNo())));
verify(facade).planShadowOutboundCandidates(eq("01"), argThat(r -> "AD.L-00033.01.01".equals(r.projectNo())), any());
}
@Test
@@ -134,9 +134,12 @@ class ShadowOutboundValidationTest {
}
@Test
void multipleRowsOnSelectedTrayAreExplicitlyUnsupported() {
void otherRowsOnSelectedTrayDoNotEraseTheEligibleItem() {
candidate.setTrayItemCount(2);
assertThat(plan().resultCode()).isEqualTo("SKIPPED_MULTI_STOCK_TRAY");
var result = plan();
assertThat(result.resultCode()).isEqualTo("READY");
assertThat(result.proposedStockId()).isEqualTo("STOCK");
assertThat(result.reason()).contains("candidateEvidenceComplete=true", "\"trayItems\":2", "\"stock\":\"STOCK\"");
}
@Test
@@ -155,4 +158,13 @@ class ShadowOutboundValidationTest {
private OutboundShadowObservation plan() {
return engine.planProjectOutbound(snapshot, "DETAIL");
}
@Test
void unsupportedDuplicateEligibleRowsRemainExplicitAndKeepEvidence() {
when(mapper.selectProjectOutboundCandidates("01", 459L, "DETAIL")).thenReturn(List.of(candidate, candidate));
var result = plan();
assertThat(result.resultCode()).isEqualTo("SKIPPED_AMBIGUOUS_STOCK_SCOPE");
assertThat(result.reason()).contains("candidateCount=2", "candidateSha256=");
verifyNoInteractions(facade);
}
}

View File

@@ -52,6 +52,7 @@ class ShadowPlanningEngineTest {
ShadowInboundReference input = inboundReference("1");
input.setShadowCandidateCount(3);
when(mapper.selectInboundInput("01", 42L, "TASK-1", "TRAY-1")).thenReturn(input);
when(mapper.selectInboundEligibleSpaces("01", 42L, "TASK-1", "TRAY-1")).thenReturn(java.util.List.of("SPACE-9"));
when(mapper.countInboundEligibleSpace("01", 42L, "TASK-1", "TRAY-1", "SPACE-9")).thenReturn(1);
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT));
when(facade.planInboundFromPublishedSnapshot(eq("01"), any()))
@@ -89,7 +90,7 @@ class ShadowPlanningEngineTest {
.thenReturn(java.util.List.of(stockReference("01")));
when(mapper.selectProjectOutboundReference("01", 42L, "DETAIL-1")).thenReturn(reference);
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT));
when(facade.planOutboundFromPublishedSnapshot(eq("01"), any()))
when(facade.planShadowOutboundCandidates(eq("01"), any(), any()))
.thenReturn(OutboundPlan.ready(outboundAssignment(), TaskCountEstimate.zero()));
ShadowPlanningEngine engine = engine(facade, mapper);
@@ -104,7 +105,7 @@ class ShadowPlanningEngineTest {
"sourceSignature=ABC123", "detail=DETAIL-1", "rawWarehouse=W02",
"project=PROJECT-1", "product=PRODUCT-1", "shadowMatches=1");
verify(mapper).selectProjectOutboundReference("01", 42L, "DETAIL-1");
verify(facade).planOutboundFromPublishedSnapshot(eq("01"), any());
verify(facade).planShadowOutboundCandidates(eq("01"), any(), eq(java.util.Set.of("STOCK-1")));
verify(facade, never()).planOutbound(any(), any());
}
@@ -359,6 +360,8 @@ class ShadowPlanningEngineTest {
}
private static ShadowPlanningEngine engine(SchedulingFacade facade, ShadowInputMapper mapper) {
when(mapper.selectInboundEligibleSpaces(any(), any(Long.class), any(), any()))
.thenReturn(java.util.List.of("SPACE-9"));
return new ShadowPlanningEngine(facade, mapper, properties(), new CapturingTransactionManager());
}

View File

@@ -0,0 +1,44 @@
package com.ruoyi.in.stockindetail.controller;
import com.ruoyi.framework.web.exception.GlobalExceptionHandler;
import com.ruoyi.in.stockindetail.service.IInStockinDetailService;
import com.ruoyi.in.stockindetailfinish.service.IInStockinDetailFinishService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
class InStockinDetailBindingTest {
@org.springframework.web.bind.annotation.RestController
static class BrokenServer {
@org.springframework.web.bind.annotation.GetMapping("/fixture/server-failure")
Object get() { throw new org.springframework.beans.InvalidPropertyException(BrokenServer.class,
"stockinId[limit]", "server property failure"); }
}
@Test
void serverPropertyFailureIsNotRelabeledAsBadClientInput() throws Exception {
MockMvcBuilders.standaloneSetup(new BrokenServer()).setControllerAdvice(new GlobalExceptionHandler()).build()
.perform(get("/fixture/server-failure").param("stockinId[limit]", "20"))
.andExpect(jsonPath("$.code").value(500));
}
@Test
void nestedOrderNumberIsRejectedBeforeEitherDatabaseQuery() throws Exception {
var controller = new InStockinDetailController();
var active = mock(IInStockinDetailService.class);
var finished = mock(IInStockinDetailFinishService.class);
ReflectionTestUtils.setField(controller, "inStockinDetailService", active);
ReflectionTestUtils.setField(controller, "inStockinDetailFinishService", finished);
var mvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler()).build();
mvc.perform(get("/in/stockInDetail/list").param("stockinId[limit]", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(400))
.andExpect(jsonPath("$.msg").value("请求参数格式不正确,单号等普通字段请传单个值,不要传对象或数组"));
verifyNoInteractions(active, finished);
}
}