From aef48983fdd20ae71ab0732e53c893aa2cd9cb52 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 11 May 2026 16:06:12 +0000 Subject: [PATCH] =?UTF-8?q?feat(gb28181):=20GB/T=2028181-2016=20=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=B1=82=E6=B6=88=E6=81=AF=EF=BC=88MANSCDP=20XML?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新模块 sip-gb28181(第 10 个模块) - GbCommand 枚举:覆盖 10 个标准 CmdType Keepalive / Catalog / DeviceInfo / DeviceStatus / DeviceControl / DeviceConfig / Alarm / MobilePosition / RecordInfo / Broadcast · of(wireName) 大小写不敏感、未知抛 IllegalArgumentException - GbXmlBuilder:手写 XML 构造器 · envelope(rootElement).text(name, value).build() · 自动 XML declaration + UTF-8 · escape: < > & " ' - GbXmlParser:手写 XML 解析器(针对 GB28181 扁平结构调优) · 接受 root + 子元素的 name/text 对 · 处理 declaration、self-closing、entity 反转义(含 numeric &#x...) · 不支持嵌套/属性/命名空间(GB28181 用不到,避免拉 JAXB) - GbMessages:常用消息建造 · keepaliveXml / catalogQueryXml / deviceInfoQueryXml / deviceStatusQueryXml · manscdpMessage:把 XML body 包到 SIP MESSAGE 请求里 · Content-Type: Application/MANSCDP+xml · Via 带 rport、User-Agent 等 · 单调 SN 序列号生成器 测试(11 个) - GbXmlTest(8 个):build/parse keepalive、Catalog query、unescape entities、 escape special chars、未知 CmdType 拒、case-insensitive lookup - GbMessagesTest(3 个):manscdpMessage SIP encode→parse→XML parse 全闭环、 Catalog query roundtrip、SN 单调 模块用 -Xlint:-module 局部抑制 'terminal digits in module name' 警告 ('gb28181' 是国标编号,命名不可妥协) mvn verify:10 模块 SUCCESS。总 169 tests。 Co-authored-by: li xuanqun <793005378@qq.com> --- pom.xml | 6 + sip-gb28181/pom.xml | 64 ++++++++ .../main/java/com/sip/gb28181/GbCommand.java | 60 +++++++ .../main/java/com/sip/gb28181/GbMessages.java | 98 ++++++++++++ .../java/com/sip/gb28181/GbXmlBuilder.java | 69 +++++++++ .../java/com/sip/gb28181/GbXmlParser.java | 146 ++++++++++++++++++ sip-gb28181/src/main/java/module-info.java | 25 +++ .../java/com/sip/gb28181/GbMessagesTest.java | 74 +++++++++ .../test/java/com/sip/gb28181/GbXmlTest.java | 94 +++++++++++ 9 files changed, 636 insertions(+) create mode 100644 sip-gb28181/pom.xml create mode 100644 sip-gb28181/src/main/java/com/sip/gb28181/GbCommand.java create mode 100644 sip-gb28181/src/main/java/com/sip/gb28181/GbMessages.java create mode 100644 sip-gb28181/src/main/java/com/sip/gb28181/GbXmlBuilder.java create mode 100644 sip-gb28181/src/main/java/com/sip/gb28181/GbXmlParser.java create mode 100644 sip-gb28181/src/main/java/module-info.java create mode 100644 sip-gb28181/src/test/java/com/sip/gb28181/GbMessagesTest.java create mode 100644 sip-gb28181/src/test/java/com/sip/gb28181/GbXmlTest.java diff --git a/pom.xml b/pom.xml index fe6e67d..456f27d 100644 --- a/pom.xml +++ b/pom.xml @@ -32,6 +32,7 @@ sip-transaction sip-dialog sip-ua + sip-gb28181 sip-compliance-tests @@ -92,6 +93,11 @@ sip-ua ${project.version} + + com.sip + sip-gb28181 + ${project.version} + diff --git a/sip-gb28181/pom.xml b/sip-gb28181/pom.xml new file mode 100644 index 0000000..f588884 --- /dev/null +++ b/sip-gb28181/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + + com.sip + sip-stack-parent + 0.1.0-SNAPSHOT + + + sip-gb28181 + SIP :: GB28181 Application Layer + + GB/T 28181-2016 video surveillance application-layer messages + (MANSCDP XML over MESSAGE / NOTIFY) and supporting types. + Builds on sip-message + sip-codec; consumes sip-ua for register flow. + + + + + com.sip + sip-message + + + com.sip + sip-codec + + + com.sip + sip-ua + + + org.slf4j + slf4j-api + + + org.slf4j + slf4j-simple + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + -Xlint:all,-serial,-processing,-module + -Werror + -parameters + + + + + + diff --git a/sip-gb28181/src/main/java/com/sip/gb28181/GbCommand.java b/sip-gb28181/src/main/java/com/sip/gb28181/GbCommand.java new file mode 100644 index 0000000..c7da275 --- /dev/null +++ b/sip-gb28181/src/main/java/com/sip/gb28181/GbCommand.java @@ -0,0 +1,60 @@ +package com.sip.gb28181; + +/** + * GB/T 28181-2016 {@code CmdType} values used in the MANSCDP envelope. + * + *

Each enum constant matches the literal token expected on the wire + * (e.g. {@code "Catalog"}, {@code "Keepalive"}). When sent in XML, the + * value is wrapped in a {@code ...} element.

+ */ +public enum GbCommand { + + /** Heartbeat — device → platform, periodic alive notification. */ + KEEPALIVE("Keepalive"), + + /** Catalog query (platform → device) and catalog response (device → platform). */ + CATALOG("Catalog"), + + /** Device basic info query / response. */ + DEVICE_INFO("DeviceInfo"), + + /** Device status query / response. */ + DEVICE_STATUS("DeviceStatus"), + + /** Device control (PTZ, recording, reboot, …). */ + DEVICE_CONTROL("DeviceControl"), + + /** Device configuration query / response. */ + DEVICE_CONFIG("DeviceConfig"), + + /** Alarm notification (device → platform). */ + ALARM("Alarm"), + + /** Mobile-position notification (subscribed location stream). */ + MOBILE_POSITION("MobilePosition"), + + /** Record history query / response. */ + RECORD_INFO("RecordInfo"), + + /** Broadcast invite (platform → device, voice broadcast). */ + BROADCAST("Broadcast"); + + private final String wireName; + + GbCommand(String wireName) { + this.wireName = wireName; + } + + public String wireName() { + return wireName; + } + + public static GbCommand of(String wireName) { + for (GbCommand c : values()) { + if (c.wireName.equalsIgnoreCase(wireName)) { + return c; + } + } + throw new IllegalArgumentException("unknown GB28181 CmdType: '" + wireName + "'"); + } +} diff --git a/sip-gb28181/src/main/java/com/sip/gb28181/GbMessages.java b/sip-gb28181/src/main/java/com/sip/gb28181/GbMessages.java new file mode 100644 index 0000000..0fa469c --- /dev/null +++ b/sip-gb28181/src/main/java/com/sip/gb28181/GbMessages.java @@ -0,0 +1,98 @@ +package com.sip.gb28181; + +import com.sip.message.SipMethod; +import com.sip.message.SipRequest; +import com.sip.message.SipVersion; +import com.sip.message.header.Headers; +import com.sip.message.uri.SipUri; +import com.sip.ua.Branch; +import com.sip.ua.SipStackInfo; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Builders for the common GB28181 message bodies and matching SIP + * envelopes. + * + *

All MANSCDP bodies are emitted as UTF-8 and carried inside a SIP + * {@code MESSAGE} request with + * {@code Content-Type: Application/MANSCDP+xml}.

+ */ +public final class GbMessages { + + /** MIME media type for MANSCDP XML bodies (GB/T 28181-2016 §9.1). */ + public static final String MANSCDP_CONTENT_TYPE = "Application/MANSCDP+xml"; + + private static final AtomicInteger SN = new AtomicInteger(); + + private GbMessages() { } + + /** Allocate a unique serial number for a query/response correlation pair. */ + public static int nextSerialNumber() { + return SN.incrementAndGet(); + } + + /** Build the XML body for a Keepalive notification. */ + public static String keepaliveXml(int sn, String deviceId, String status) { + return GbXmlBuilder.envelope("Notify") + .text("CmdType", GbCommand.KEEPALIVE.wireName()) + .text("SN", Integer.toString(sn)) + .text("DeviceID", deviceId) + .text("Status", status == null ? "OK" : status) + .build(); + } + + /** Build the XML body for a Catalog query (platform → device). */ + public static String catalogQueryXml(int sn, String deviceId) { + return GbXmlBuilder.envelope("Query") + .text("CmdType", GbCommand.CATALOG.wireName()) + .text("SN", Integer.toString(sn)) + .text("DeviceID", deviceId) + .build(); + } + + /** Build the XML body for a DeviceInfo query (platform → device). */ + public static String deviceInfoQueryXml(int sn, String deviceId) { + return GbXmlBuilder.envelope("Query") + .text("CmdType", GbCommand.DEVICE_INFO.wireName()) + .text("SN", Integer.toString(sn)) + .text("DeviceID", deviceId) + .build(); + } + + /** Build the XML body for a DeviceStatus query. */ + public static String deviceStatusQueryXml(int sn, String deviceId) { + return GbXmlBuilder.envelope("Query") + .text("CmdType", GbCommand.DEVICE_STATUS.wireName()) + .text("SN", Integer.toString(sn)) + .text("DeviceID", deviceId) + .build(); + } + + /** + * Wrap an arbitrary MANSCDP XML payload in a SIP MESSAGE request + * suitable for sending to {@code targetAor} from {@code localAor}. + */ + public static SipRequest manscdpMessage(SipUri targetAor, SipUri localAor, + SipUri requestUri, + String fromTag, String callId, + int cseqNumber, String xmlBody, + String viaSentBy) { + byte[] body = xmlBody.getBytes(StandardCharsets.UTF_8); + Headers headers = Headers.builder() + .add("Via", "SIP/2.0/UDP " + viaSentBy + + ";branch=" + Branch.newBranch() + ";rport") + .add("Max-Forwards", "70") + .add("From", "<" + localAor.asWire() + ">;tag=" + fromTag) + .add("To", "<" + targetAor.asWire() + ">") + .add("Call-ID", callId) + .add("CSeq", cseqNumber + " MESSAGE") + .add("Content-Type", MANSCDP_CONTENT_TYPE) + .add("Content-Length", Integer.toString(body.length)) + .add("User-Agent", SipStackInfo.userAgent()) + .build(); + return new SipRequest(SipMethod.MESSAGE, requestUri, + SipVersion.SIP_2_0, headers, body); + } +} diff --git a/sip-gb28181/src/main/java/com/sip/gb28181/GbXmlBuilder.java b/sip-gb28181/src/main/java/com/sip/gb28181/GbXmlBuilder.java new file mode 100644 index 0000000..a56ba56 --- /dev/null +++ b/sip-gb28181/src/main/java/com/sip/gb28181/GbXmlBuilder.java @@ -0,0 +1,69 @@ +package com.sip.gb28181; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Tiny element-and-text XML builder tuned for the patterns GB28181 uses. + * + *

GB28181 MANSCDP bodies are flat element trees with no attributes, + * no namespaces, and CDATA-free text. A hand-rolled builder is faster + * and less error-prone than dragging in JAXB just for this.

+ * + *
{@code
+ *   String xml = GbXmlBuilder.envelope("Query")
+ *       .text("CmdType", "Catalog")
+ *       .text("SN", "1")
+ *       .text("DeviceID", "34020000001320000001")
+ *       .build();
+ * }
+ * + *

Output is always declared as UTF-8 and ends with a single newline.

+ */ +public final class GbXmlBuilder { + + private final String rootElement; + private final Map children = new LinkedHashMap<>(); + + private GbXmlBuilder(String rootElement) { + this.rootElement = rootElement; + } + + public static GbXmlBuilder envelope(String rootElement) { + return new GbXmlBuilder(rootElement); + } + + public GbXmlBuilder text(String element, String value) { + children.put(element, value == null ? "" : value); + return this; + } + + public String build() { + StringBuilder sb = new StringBuilder(256); + sb.append("\n"); + sb.append('<').append(rootElement).append(">\n"); + for (var e : children.entrySet()) { + sb.append(" <").append(e.getKey()).append('>') + .append(escape(e.getValue())) + .append("\n"); + } + sb.append("\n"); + return sb.toString(); + } + + private static String escape(String s) { + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '<' -> sb.append("<"); + case '>' -> sb.append(">"); + case '&' -> sb.append("&"); + case '"' -> sb.append("""); + case '\'' -> sb.append("'"); + default -> sb.append(c); + } + } + return sb.toString(); + } +} diff --git a/sip-gb28181/src/main/java/com/sip/gb28181/GbXmlParser.java b/sip-gb28181/src/main/java/com/sip/gb28181/GbXmlParser.java new file mode 100644 index 0000000..0c4a1b7 --- /dev/null +++ b/sip-gb28181/src/main/java/com/sip/gb28181/GbXmlParser.java @@ -0,0 +1,146 @@ +package com.sip.gb28181; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Tiny XML parser tuned for GB28181 MANSCDP bodies. + * + *

Handles the limited subset GB28181 actually uses: a single root + * element containing flat {@code text} children. No + * attributes, no nested elements, no namespaces.

+ * + *

For documents outside this subset (e.g. nested {@code } + * with {@code } children), use a real XML parser. The parser is + * intentionally minimal to keep the module dependency-free.

+ */ +public final class GbXmlParser { + + private GbXmlParser() { } + + public static Parsed parse(String xml) { + if (xml == null) { + throw new IllegalArgumentException("xml is null"); + } + String s = xml.trim(); + // Strip XML declaration if present. + if (s.startsWith(""); + if (end < 0) { + throw new IllegalArgumentException("unterminated XML declaration"); + } + s = s.substring(end + 2).trim(); + } + + int rootOpen = s.indexOf('<'); + if (rootOpen < 0) { + throw new IllegalArgumentException("no root element"); + } + int rootOpenEnd = s.indexOf('>', rootOpen + 1); + if (rootOpenEnd < 0) { + throw new IllegalArgumentException("unterminated root opening tag"); + } + String rootName = s.substring(rootOpen + 1, rootOpenEnd).trim(); + String closeTag = ""; + int closeIdx = s.lastIndexOf(closeTag); + if (closeIdx < 0) { + throw new IllegalArgumentException("missing closing tag for <" + rootName + ">"); + } + + String inner = s.substring(rootOpenEnd + 1, closeIdx); + Map children = new LinkedHashMap<>(); + int i = 0; + while (i < inner.length()) { + int open = inner.indexOf('<', i); + if (open < 0) break; + int openEnd = inner.indexOf('>', open + 1); + if (openEnd < 0) { + throw new IllegalArgumentException("unterminated opening tag at offset " + open); + } + String tagOpen = inner.substring(open + 1, openEnd).trim(); + if (tagOpen.startsWith("/")) { + // mismatched closer + throw new IllegalArgumentException( + "unexpected closing tag: '" + tagOpen + "'"); + } + // self-closing? + if (tagOpen.endsWith("/")) { + String name = tagOpen.substring(0, tagOpen.length() - 1).trim(); + children.put(name, ""); + i = openEnd + 1; + continue; + } + String name = tagOpen; + // find matching closer + String close = ""; + int cIdx = inner.indexOf(close, openEnd + 1); + if (cIdx < 0) { + throw new IllegalArgumentException("missing closing tag for <" + name + ">"); + } + String value = unescape(inner.substring(openEnd + 1, cIdx)); + children.put(name, value); + i = cIdx + close.length(); + } + return new Parsed(rootName, children); + } + + private static String unescape(String s) { + StringBuilder sb = new StringBuilder(s.length()); + int i = 0; + while (i < s.length()) { + char c = s.charAt(i); + if (c == '&') { + int semi = s.indexOf(';', i + 1); + if (semi < 0) { + sb.append(c); + i++; + continue; + } + String entity = s.substring(i + 1, semi); + switch (entity) { + case "lt" -> sb.append('<'); + case "gt" -> sb.append('>'); + case "amp" -> sb.append('&'); + case "quot" -> sb.append('"'); + case "apos" -> sb.append('\''); + default -> { + if (entity.startsWith("#")) { + try { + int code = entity.startsWith("#x") || entity.startsWith("#X") + ? Integer.parseInt(entity.substring(2), 16) + : Integer.parseInt(entity.substring(1)); + sb.appendCodePoint(code); + } catch (NumberFormatException ignore) { + sb.append('&').append(entity).append(';'); + } + } else { + sb.append('&').append(entity).append(';'); + } + } + } + i = semi + 1; + } else { + sb.append(c); + i++; + } + } + return sb.toString(); + } + + /** Result of parsing a flat MANSCDP-style XML document. */ + public record Parsed(String rootElement, Map children) { + + public Parsed { + children = Map.copyOf(children); + } + + public Optional get(String name) { + return Optional.ofNullable(children.get(name)); + } + + public Optional command() { + return get("CmdType").map(GbCommand::of); + } + } +} diff --git a/sip-gb28181/src/main/java/module-info.java b/sip-gb28181/src/main/java/module-info.java new file mode 100644 index 0000000..59e8072 --- /dev/null +++ b/sip-gb28181/src/main/java/module-info.java @@ -0,0 +1,25 @@ +/** + * GB/T 28181-2016 application-layer messages. + * + *

GB28181 carries device control and event data inside SIP MESSAGE / + * NOTIFY bodies using MANSCDP XML. This module provides:

+ * + *
    + *
  • {@link com.sip.gb28181.GbCommand} — the canonical command vocabulary + * (Catalog, Keepalive, DeviceInfo, Alarm, …)
  • + *
  • {@link com.sip.gb28181.GbXmlBuilder} / {@link com.sip.gb28181.GbXmlParser} + * — minimal XML support tuned to the patterns GB28181 actually uses
  • + *
  • High-level message DTOs and assemblers for the common cases.
  • + *
+ * + *

For SIP framing, devices use the normal {@code MESSAGE} method with + * {@code Content-Type: Application/MANSCDP+xml}.

+ */ +module com.sip.gb28181 { + requires transitive com.sip.message; + requires transitive com.sip.ua; + requires com.sip.codec; + requires org.slf4j; + + exports com.sip.gb28181; +} diff --git a/sip-gb28181/src/test/java/com/sip/gb28181/GbMessagesTest.java b/sip-gb28181/src/test/java/com/sip/gb28181/GbMessagesTest.java new file mode 100644 index 0000000..d3599ab --- /dev/null +++ b/sip-gb28181/src/test/java/com/sip/gb28181/GbMessagesTest.java @@ -0,0 +1,74 @@ +package com.sip.gb28181; + +import com.sip.codec.SipEncoder; +import com.sip.codec.SipParser; +import com.sip.message.SipMessage; +import com.sip.message.SipMethod; +import com.sip.message.SipRequest; +import com.sip.message.header.HeaderName; +import com.sip.message.uri.SipUri; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class GbMessagesTest { + + @Test + void manscdpMessageEncodesAndParsesIdentically() { + SipUri target = SipUri.builder() + .user("34020000001320000001").host("3402000000").build(); + SipUri local = SipUri.builder() + .user("34020000002000000001").host("3402000000").build(); + String xml = GbMessages.keepaliveXml(1, "34020000001320000001", "OK"); + + SipRequest req = GbMessages.manscdpMessage(target, local, target, + "ftag-1", "callid@gb28181", 1, xml, "127.0.0.1:5060"); + + // wire encode → parse → verify + byte[] bytes = SipEncoder.encode(req); + SipMessage parsed = SipParser.parse(bytes); + assertThat(parsed).isInstanceOf(SipRequest.class); + SipRequest got = (SipRequest) parsed; + assertThat(got.method()).isEqualTo(SipMethod.MESSAGE); + assertThat(got.requestUri().asWire()) + .isEqualTo("sip:34020000001320000001@3402000000"); + assertThat(got.headers().first(HeaderName.CONTENT_TYPE).orElseThrow().value()) + .isEqualTo(GbMessages.MANSCDP_CONTENT_TYPE); + + String parsedBody = new String(got.body(), StandardCharsets.UTF_8); + assertThat(parsedBody).contains("Keepalive"); + assertThat(parsedBody).contains("34020000001320000001"); + + GbXmlParser.Parsed xmlParsed = GbXmlParser.parse(parsedBody); + assertThat(xmlParsed.command()).contains(GbCommand.KEEPALIVE); + } + + @Test + void catalogQueryRoundtripsThroughSipAndXml() { + SipUri target = SipUri.builder().user("dev").host("h").build(); + SipUri local = SipUri.builder().user("plat").host("h").build(); + int sn = GbMessages.nextSerialNumber(); + String xml = GbMessages.catalogQueryXml(sn, "34020000001320000001"); + + SipRequest req = GbMessages.manscdpMessage(target, local, target, + "ftag-2", "cid@x", 1, xml, "127.0.0.1:5060"); + byte[] bytes = SipEncoder.encode(req); + + SipRequest parsed = (SipRequest) SipParser.parse(bytes); + String body = new String(parsed.body(), StandardCharsets.UTF_8); + GbXmlParser.Parsed p = GbXmlParser.parse(body); + assertThat(p.rootElement()).isEqualTo("Query"); + assertThat(p.command()).contains(GbCommand.CATALOG); + assertThat(p.get("SN")).contains(Integer.toString(sn)); + } + + @Test + void serialNumbersAreMonotonic() { + int a = GbMessages.nextSerialNumber(); + int b = GbMessages.nextSerialNumber(); + int c = GbMessages.nextSerialNumber(); + assertThat(a).isLessThan(b).isLessThan(c); + } +} diff --git a/sip-gb28181/src/test/java/com/sip/gb28181/GbXmlTest.java b/sip-gb28181/src/test/java/com/sip/gb28181/GbXmlTest.java new file mode 100644 index 0000000..a99fb60 --- /dev/null +++ b/sip-gb28181/src/test/java/com/sip/gb28181/GbXmlTest.java @@ -0,0 +1,94 @@ +package com.sip.gb28181; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GbXmlTest { + + @Test + void buildsKeepaliveXml() { + String xml = GbMessages.keepaliveXml(42, "34020000001320000001", "OK"); + assertThat(xml).contains(""); + assertThat(xml).contains("Keepalive"); + assertThat(xml).contains("42"); + assertThat(xml).contains("34020000001320000001"); + assertThat(xml).contains("OK"); + } + + @Test + void buildsCatalogQueryXml() { + String xml = GbMessages.catalogQueryXml(7, "34020000001320000001"); + assertThat(xml).contains("Catalog"); + assertThat(xml).contains("7"); + } + + @Test + void parsesFlatEnvelope() { + String xml = """ + + + Keepalive + 1 + 34020000001320000001 + OK + + """; + GbXmlParser.Parsed p = GbXmlParser.parse(xml); + assertThat(p.rootElement()).isEqualTo("Notify"); + assertThat(p.command()).contains(GbCommand.KEEPALIVE); + assertThat(p.get("SN")).contains("1"); + assertThat(p.get("DeviceID")).contains("34020000001320000001"); + } + + @Test + void roundtripsKeepalive() { + String original = GbMessages.keepaliveXml(99, "34020000001320000001", "OK"); + GbXmlParser.Parsed p = GbXmlParser.parse(original); + assertThat(p.rootElement()).isEqualTo("Notify"); + assertThat(p.command()).contains(GbCommand.KEEPALIVE); + assertThat(p.get("SN")).contains("99"); + assertThat(p.get("Status")).contains("OK"); + } + + @Test + void unescapesEntities() { + String xml = """ + + A & B <test> + + """; + GbXmlParser.Parsed p = GbXmlParser.parse(xml); + assertThat(p.get("Name")).contains("A & B "); + } + + @Test + void escapesSpecialChars() { + String xml = GbXmlBuilder.envelope("Foo") + .text("Bar", "A & B ") + .build(); + assertThat(xml).contains("&"); + assertThat(xml).contains("<"); + assertThat(xml).contains(">"); + // round-trip + assertThat(GbXmlParser.parse(xml).get("Bar")) + .contains("A & B "); + } + + @Test + void rejectsUnknownCmdType() { + assertThatThrownBy(() -> GbCommand.of("WhatIsThis")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void recognizesAllStandardCommands() { + assertThat(GbCommand.of("Catalog")).isEqualTo(GbCommand.CATALOG); + assertThat(GbCommand.of("DeviceInfo")).isEqualTo(GbCommand.DEVICE_INFO); + assertThat(GbCommand.of("Alarm")).isEqualTo(GbCommand.ALARM); + assertThat(GbCommand.of("MobilePosition")).isEqualTo(GbCommand.MOBILE_POSITION); + // case insensitive + assertThat(GbCommand.of("catalog")).isEqualTo(GbCommand.CATALOG); + } +}