fix(device): 完善 BTS 状态与模拟器契约
| | |
| | | public List<DetectedChannel> parseDeviceInfo(String xml) |
| | | { |
| | | Document document = parse(xml, "getdevinfo_resp"); |
| | | NodeList nodes = document.getElementsByTagName("channel"); |
| | | NodeList nodes = firstElement(document, "middle").getElementsByTagName("channel"); |
| | | List<DetectedChannel> channels = new ArrayList<>(nodes.getLength()); |
| | | for (int i = 0; i < nodes.getLength(); i++) |
| | | { |
| | |
| | | public Map<ChannelIdentity, RealtimeSnapshot> parseSnapshots(String xml, Instant receivedAt) |
| | | { |
| | | Document document = parse(xml, "inquire_resp"); |
| | | NodeList nodes = document.getElementsByTagName("inquire"); |
| | | NodeList nodes = firstElement(document, "list").getElementsByTagName("inquire"); |
| | | Map<ChannelIdentity, RealtimeSnapshot> snapshots = new LinkedHashMap<>(); |
| | | for (int i = 0; i < nodes.getLength(); i++) |
| | | { |
| | |
| | | public List<HistoryAvailability> parseHistoryAvailability(String xml) |
| | | { |
| | | Document document = parse(xml, "inquiredf_resp"); |
| | | NodeList nodes = document.getElementsByTagName("chl"); |
| | | NodeList nodes = firstElement(document, "list").getElementsByTagName("chl"); |
| | | List<HistoryAvailability> availability = new ArrayList<>(nodes.getLength()); |
| | | for (int i = 0; i < nodes.getLength(); i++) |
| | | { |
| | |
| | | || !btsTestId.equals(requestedTestId)) |
| | | throw new BtsProtocolException(BtsProtocolException.Reason.IDENTITY_MISMATCH, |
| | | "BTS history response identity does not match the request"); |
| | | NodeList nodes = document.getElementsByTagName("data"); |
| | | NodeList nodes = firstElement(document, "list").getElementsByTagName("data"); |
| | | List<HistorySample> samples = new ArrayList<>(nodes.getLength()); |
| | | long next = requestedStart; |
| | | for (int i = 0; i < nodes.getLength(); i++) |
| File was renamed from ruoyi-device/src/test/java/com/ruoyi/device/bts/ScriptedBtsTcpSimulator.java |
| | |
| | | import java.util.concurrent.LinkedBlockingQueue; |
| | | import java.util.concurrent.TimeUnit; |
| | | |
| | | final class ScriptedBtsTcpSimulator implements AutoCloseable |
| | | /** |
| | | * 提供可复用的 BTS TCP 脚本设备边界,用于闭环与恢复测试。 |
| | | */ |
| | | public final class ScriptedBtsTcpSimulator implements AutoCloseable |
| | | { |
| | | private static final byte[] FRAME_END = "#\r\n".getBytes(StandardCharsets.UTF_8); |
| | | |
| | |
| | | private final BlockingQueue<ScriptedAction> actions = new LinkedBlockingQueue<>(); |
| | | private final List<String> requests = new CopyOnWriteArrayList<>(); |
| | | |
| | | ScriptedBtsTcpSimulator() throws IOException |
| | | public ScriptedBtsTcpSimulator() throws IOException |
| | | { |
| | | this(true); |
| | | } |
| | |
| | | serverSocket.close(); |
| | | } |
| | | |
| | | static ScriptedBtsTcpSimulator refusingConnections() throws IOException |
| | | public static ScriptedBtsTcpSimulator refusingConnections() throws IOException |
| | | { |
| | | return new ScriptedBtsTcpSimulator(false); |
| | | } |
| | | |
| | | int port() |
| | | public int port() |
| | | { |
| | | return port; |
| | | } |
| | | |
| | | void enqueue(String response) |
| | | public void enqueue(String response) |
| | | { |
| | | actions.add(new ScriptedAction(Duration.ZERO, response.getBytes(StandardCharsets.UTF_8), false)); |
| | | } |
| | | |
| | | void enqueueDelayed(Duration delay, String response) |
| | | public void enqueueDelayed(Duration delay, String response) |
| | | { |
| | | if (delay.isNegative()) throw new IllegalArgumentException("delay must not be negative"); |
| | | actions.add(new ScriptedAction(delay, response.getBytes(StandardCharsets.UTF_8), false)); |
| | | } |
| | | |
| | | void enqueueRaw(byte[] response) |
| | | public void enqueueRaw(byte[] response) |
| | | { |
| | | actions.add(new ScriptedAction(Duration.ZERO, response.clone(), false)); |
| | | } |
| | | |
| | | void enqueueDisconnect() |
| | | public void enqueueDisconnect() |
| | | { |
| | | actions.add(new ScriptedAction(Duration.ZERO, null, true)); |
| | | } |
| | | |
| | | List<String> requests() |
| | | public List<String> requests() |
| | | { |
| | | return List.copyOf(requests); |
| | | } |
| | |
| | | public enum DeviceWorkStatus |
| | | { |
| | | WORKING, |
| | | FINISHED, |
| | | STOPPED, |
| | | PROTECTED, |
| | | PAUSED, |
| | |
| | | return switch (value.trim().toLowerCase(Locale.ROOT)) |
| | | { |
| | | case "working" -> WORKING; |
| | | case "finish" -> FINISHED; |
| | | case "stop" -> STOPPED; |
| | | case "protect" -> PROTECTED; |
| | | case "pause" -> PAUSED; |
| | |
| | | } |
| | | |
| | | @Test |
| | | void mapsFinishAsNormalCompletion() |
| | | { |
| | | String xml = "<bts><cmd>inquire_resp</cmd><list count=\"1\">" |
| | | + "<inquire dev=\"22-1-1-1-0\" workstatus=\"finish\"/></list></bts>"; |
| | | |
| | | RealtimeSnapshot snapshot = codec.parseSnapshots(xml, Instant.EPOCH).values().iterator().next(); |
| | | |
| | | assertEquals(DeviceWorkStatus.FINISHED, snapshot.workStatus()); |
| | | } |
| | | |
| | | @Test |
| | | void parsesHistoryPageAndHongKongDeviceTime() |
| | | { |
| | | ChannelIdentity channel = new ChannelIdentity("lab-bts", 24, 1, 1, 1); |
| | |
| | | } |
| | | |
| | | @Test |
| | | void rejectsResponsesMissingRequiredContainers() |
| | | { |
| | | BtsProtocolException discovery = assertThrows(BtsProtocolException.class, |
| | | () -> codec.parseDeviceInfo("<bts><cmd>getdevinfo_resp</cmd></bts>")); |
| | | BtsProtocolException realtime = assertThrows(BtsProtocolException.class, |
| | | () -> codec.parseSnapshots("<bts><cmd>inquire_resp</cmd></bts>", Instant.EPOCH)); |
| | | BtsProtocolException availability = assertThrows(BtsProtocolException.class, |
| | | () -> codec.parseHistoryAvailability("<bts><cmd>inquiredf_resp</cmd></bts>")); |
| | | ChannelIdentity channel = new ChannelIdentity("lab-bts", 24, 1, 1, 1); |
| | | BtsProtocolException history = assertThrows(BtsProtocolException.class, |
| | | () -> codec.parseHistory("<bts><cmd>download_resp</cmd>" |
| | | + "<download devtype=\"24\" devid=\"1\" subdevid=\"1\" chlid=\"1\" testid=\"42\"/>" |
| | | + "</bts>", channel, "42", 1)); |
| | | |
| | | assertEquals(BtsProtocolException.Reason.MISSING_FIELD, discovery.reason()); |
| | | assertEquals(BtsProtocolException.Reason.MISSING_FIELD, realtime.reason()); |
| | | assertEquals(BtsProtocolException.Reason.MISSING_FIELD, availability.reason()); |
| | | assertEquals(BtsProtocolException.Reason.MISSING_FIELD, history.reason()); |
| | | } |
| | | |
| | | @Test |
| | | void rejectsDoctypeAndExternalEntities() |
| | | { |
| | | String xml = "<!DOCTYPE bts [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]><bts><cmd>inquire_resp</cmd>&xxe;</bts>"; |
| New file |
| | |
| | | package com.ruoyi.device.contract; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import java.time.Duration; |
| | | import org.junit.jupiter.api.Test; |
| | | import com.ruoyi.device.bts.BtsTcpTransport; |
| | | import com.ruoyi.device.bts.ScriptedBtsTcpSimulator; |
| | | |
| | | class SimulatorPublicContractTest |
| | | { |
| | | @Test |
| | | void simulatorIsReusableOutsideTheBtsPackage() throws Exception |
| | | { |
| | | try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator(); |
| | | BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(), |
| | | Duration.ofSeconds(1), Duration.ofSeconds(1))) |
| | | { |
| | | simulator.enqueue("<bts><cmd>getdevinfo_resp</cmd><middle count=\"0\"/></bts>"); |
| | | |
| | | assertEquals("<bts><cmd>getdevinfo_resp</cmd><middle count=\"0\"/></bts>", |
| | | transport.exchange("<bts><cmd>getdevinfo</cmd></bts>")); |
| | | assertEquals(1, simulator.requests().size()); |
| | | } |
| | | } |
| | | } |