tests first: write_admitted messageId rule vectors (failing)
Pins Heimdall's rule: a write_admitted messageId needs a Unicode letter or number and no Default_Ignorable_Code_Point, compared raw. Shared vectors plus a runner test in TS, Python, Kotlin, C# and Rust. No decoder changed; the refuse vectors fail. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
325da81428
commit
4571a0d4e7
7 changed files with 454 additions and 4 deletions
|
|
@ -149,6 +149,280 @@
|
|||
{ "name": "empty_tag_list", "set": { "tags": [] }, "outcome": "admitted" }
|
||||
]
|
||||
},
|
||||
"admittedMessageId": {
|
||||
"semantics": "Decoder-side rule for a `cultnet.write_admitted.v0` frame's `messageId` (Heimdall's ruling, write-receipt.md). Valid only if it contains at least one Unicode letter (\\p{L}) or number (\\p{N}) AND no Default_Ignorable_Code_Point (DerivedCoreProperties). The compare is RAW: the id is echoed byte for byte, so a decoder neither trims nor normalizes. Each case's id is given as `codePoints` (hex, in order), repeated `repeat` times (default 1), so no lone surrogate appears in this file. A runner builds the frame {schemaVersion write_admitted.v0, messageId, operation put, schemaId note.v0, recordKey note:1} with its own encoder and decodes it with the write_admitted decoder; `refuse` means the decoder refuses the frame the way it refuses an empty or ill-typed messageId, `accept` means it decodes and the id comes back identical. A case with `wireHex` carries the whole frame in MessagePack hex, for an id a language string cannot hold: the lone surrogate U+D800 is not valid UTF-8, so the vector's messageId is a MessagePack str whose bytes are ED A0 80, and Rust, C#, Kotlin and Python build the frame from those bytes and must refuse it. Scope: write_admitted only; error.v1 is untouched.",
|
||||
"cases": [
|
||||
{
|
||||
"name": "refuse_braille_blank_2800",
|
||||
"codePoints": [
|
||||
"2800"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+2800 is Default_Ignorable and not a letter or number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_hangul_filler_3164",
|
||||
"codePoints": [
|
||||
"3164"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+3164 Default_Ignorable (a letter by category Lo, so DI decides)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_hangul_choseong_filler_115F",
|
||||
"codePoints": [
|
||||
"115F"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+115F Default_Ignorable (category Lo)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_halfwidth_hangul_filler_FFA0",
|
||||
"codePoints": [
|
||||
"FFA0"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+FFA0 Default_Ignorable (category Lo)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_combining_grapheme_joiner_034F",
|
||||
"codePoints": [
|
||||
"034F"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+034F Default_Ignorable"
|
||||
},
|
||||
{
|
||||
"name": "refuse_combining_mark_alone_0300",
|
||||
"codePoints": [
|
||||
"0300"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a combining mark is not a letter or number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_private_use_E000",
|
||||
"codePoints": [
|
||||
"E000"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "private use is neither a letter nor a number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_unassigned_0378",
|
||||
"codePoints": [
|
||||
"0378"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "unassigned since Unicode 1.1, neither a letter nor a number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_lone_surrogate_D800",
|
||||
"codePoints": [
|
||||
"D800"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "not a Unicode scalar value; on the wire it is the invalid UTF-8 bytes ED A0 80 in a str, so the decoder must refuse the frame. Rust, C#, Kotlin and Python build the frame from wireHex; a runtime whose strings can hold a lone surrogate (TypeScript) may also build it from the code point",
|
||||
"wireHex": "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A3EDA080A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"
|
||||
},
|
||||
{
|
||||
"name": "refuse_control_nul_0000",
|
||||
"codePoints": [
|
||||
"0000"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a control character is neither a letter nor a number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_control_bel_0007",
|
||||
"codePoints": [
|
||||
"0007"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a control character is neither a letter nor a number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_control_unit_separator_001F",
|
||||
"codePoints": [
|
||||
"001F"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a control character is neither a letter nor a number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_control_delete_007F",
|
||||
"codePoints": [
|
||||
"007F"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a control character is neither a letter nor a number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_control_next_line_0085",
|
||||
"codePoints": [
|
||||
"0085"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a control character is neither a letter nor a number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_empty",
|
||||
"codePoints": [],
|
||||
"expect": "refuse",
|
||||
"why": "empty"
|
||||
},
|
||||
{
|
||||
"name": "refuse_whitespace_only_space_0020",
|
||||
"codePoints": [
|
||||
"0020"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "white space only"
|
||||
},
|
||||
{
|
||||
"name": "refuse_whitespace_only_nbsp_00A0",
|
||||
"codePoints": [
|
||||
"00A0"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "white space only"
|
||||
},
|
||||
{
|
||||
"name": "refuse_whitespace_only_ideographic_space_3000",
|
||||
"codePoints": [
|
||||
"3000"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "white space only"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_zwsp_letter",
|
||||
"codePoints": [
|
||||
"0061",
|
||||
"200B",
|
||||
"0062"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a Default_Ignorable (U+200B) beside letters"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_cgj",
|
||||
"codePoints": [
|
||||
"0061",
|
||||
"034F"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a Default_Ignorable (U+034F) after a letter"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_soft_hyphen",
|
||||
"codePoints": [
|
||||
"0061",
|
||||
"00AD"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+00AD is Default_Ignorable"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_zwj",
|
||||
"codePoints": [
|
||||
"0061",
|
||||
"200D"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+200D is Default_Ignorable"
|
||||
},
|
||||
{
|
||||
"name": "refuse_digit_mongolian_vowel_separator",
|
||||
"codePoints": [
|
||||
"0031",
|
||||
"180E"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+180E is Default_Ignorable since Unicode 6.3"
|
||||
},
|
||||
{
|
||||
"name": "refuse_digit_word_joiner_digit",
|
||||
"codePoints": [
|
||||
"0031",
|
||||
"2060",
|
||||
"0032"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+2060 is Default_Ignorable"
|
||||
},
|
||||
{
|
||||
"name": "accept_abc",
|
||||
"codePoints": [
|
||||
"0061",
|
||||
"0062",
|
||||
"0063"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "letters"
|
||||
},
|
||||
{
|
||||
"name": "accept_digit_1",
|
||||
"codePoints": [
|
||||
"0031"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "a number"
|
||||
},
|
||||
{
|
||||
"name": "accept_padded_a_raw_compare",
|
||||
"codePoints": [
|
||||
"0020",
|
||||
"0061",
|
||||
"0020"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "raw compare: the id is echoed byte for byte, never trimmed, so the padding stays and the letter makes it valid"
|
||||
},
|
||||
{
|
||||
"name": "accept_e_acute_precomposed",
|
||||
"codePoints": [
|
||||
"00E9"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "a letter"
|
||||
},
|
||||
{
|
||||
"name": "accept_arabic_indic_digit_three",
|
||||
"codePoints": [
|
||||
"0663"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "category Nd, a number"
|
||||
},
|
||||
{
|
||||
"name": "accept_cjk_zhong",
|
||||
"codePoints": [
|
||||
"4E2D"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "a letter (Lo, not Default_Ignorable)"
|
||||
},
|
||||
{
|
||||
"name": "accept_letter_then_combining_acute",
|
||||
"codePoints": [
|
||||
"0065",
|
||||
"0301"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "a combining mark after a letter is allowed"
|
||||
},
|
||||
{
|
||||
"name": "accept_256_ascii_letters",
|
||||
"codePoints": [
|
||||
"0061"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "256 bytes is the size limit and is allowed",
|
||||
"repeat": 256
|
||||
}
|
||||
]
|
||||
},
|
||||
"schemaInvalid": [
|
||||
{
|
||||
"name": "write_admitted_with_an_error_key",
|
||||
|
|
@ -1182,7 +1456,8 @@
|
|||
"wire": 7,
|
||||
"schemaInvalid": 7,
|
||||
"contentHashCases": 4,
|
||||
"scenarioCases": 25
|
||||
"scenarioCases": 25,
|
||||
"admittedMessageIdCases": 32
|
||||
},
|
||||
"contentHash": {
|
||||
"definition": "sha256(msgpack([operation, schemaId, recordKey, payload])), payload a msgpack bin for a put and nil for a delete; hex lowercase",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ A put or delete today has two answers on the wire: nothing (admitted, or the ser
|
|||
|
||||
**`cultnet.write_admitted.v0`**: `{ schemaVersion, messageId, operation: "put"|"delete", schemaId, recordKey }`, every key written, none nil. Sent once a put or delete is admitted. The id echoes the write's `messageId` byte for byte; `operation`, `schemaId`, `recordKey` echo the write.
|
||||
|
||||
**A `write_admitted` `messageId` is valid only if it contains at least one Unicode letter (`\p{L}`) or number (`\p{N}`) and no Default_Ignorable_Code_Point (DerivedCoreProperties).** The compare is raw: the id is echoed byte for byte, so a decoder never trims or normalizes it (`" a "` is valid; U+2800 is not, and neither is `a` followed by U+200B). Unlike the Required fields rule, this is not a White_Space test. A decoder refuses an invalid id the way it refuses an empty or ill-typed one; the rule is for `write_admitted` only and `error.v1` is unchanged.
|
||||
|
||||
**`cultnet.error.v1`**: `cultnet.error.v0` plus a required `messageId` (string or nil). The code enum is v0's plus `limit_reached` (see Remembered-write limits); `cultnet.error.v1.schema.json` is the authority for the closed set. Sent in place of `error.v0` to a receipt-capable connection. `messageId` is the id of the request the error answers; nil only when the frame was unreadable or carried no id.
|
||||
|
||||
Why a new error version and not an extra key on v0: the v0 schema is closed (`additionalProperties: false`) and the TypeScript decoder (Ajv over the closed schemas) rejects unknown keys and unknown `schemaVersion`s, and `parseCultNetMessage` throws on an unknown version; Rust's decoder errors on an unknown version too. Unknown-key tolerance of the Rust, Python and Kotlin error decoders was not checked, so none is assumed, so a surprise key or frame breaks an old client's whole receive path. Old clients are therefore never sent either new frame. This reverses the "old clients ignore it" assumption, which is false for today's decoders.
|
||||
|
|
@ -65,7 +67,7 @@ Those clients complete *successfully* when the window ends with no refusal. Unde
|
|||
|
||||
## Vectors
|
||||
|
||||
`write-receipt-vectors.json`: `constants` (the named windows and retention), `contentHash` (worked dedupe hashes), `dedupe` (server replay cases for rule 5), `wire` (MessagePack hex plus the decoded message for each new frame; the bytes are spec-authored with msgpack-python and every runtime's first conformance PR re-captures them with its own encoder and must match), `schemaInvalid` (frames that must be refused by a validator, each with why), and `blankFields` (server input cases for the Required fields rule: which document fields make a put unreadable, which are accepted), `scenarios` (event replays with the outcome each write must settle as; the clock is abstract; each case names its window constant). A conformant client passes every scenario; a conformant server's answers for the same inputs satisfy server rules 1 to 7.
|
||||
`write-receipt-vectors.json`: `constants` (the named windows and retention), `contentHash` (worked dedupe hashes), `dedupe` (server replay cases for rule 5), `wire` (MessagePack hex plus the decoded message for each new frame; the bytes are spec-authored with msgpack-python and every runtime's first conformance PR re-captures them with its own encoder and must match), `schemaInvalid` (frames that must be refused by a validator, each with why), and `blankFields` (server input cases for the Required fields rule: which document fields make a put unreadable, which are accepted), `admittedMessageId` (the `write_admitted` id rule above: code-point lists, each marked refuse or accept; the lone surrogate U+D800 is carried as a whole frame in `wireHex`, because it is not valid UTF-8 and only some runtimes' strings can hold it), `scenarios` (event replays with the outcome each write must settle as; the clock is abstract; each case names its window constant). A conformant client passes every scenario; a conformant server's answers for the same inputs satisfy server rules 1 to 7.
|
||||
|
||||
## Conformance status
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,26 @@ fun replayWriteReceiptVectors(path: String): Boolean {
|
|||
}
|
||||
}
|
||||
|
||||
// admittedMessageId: a write_admitted messageId needs a letter or number and no Default_Ignorable code point, compared
|
||||
// raw. The frame is built with this runtime's own encoder; a case with `wireHex` (the lone surrogate, invalid UTF-8)
|
||||
// supplies the whole frame.
|
||||
for (case in list(obj(vectors["admittedMessageId"])["cases"])) {
|
||||
val c = obj(case)
|
||||
check("admittedMessageId/${c["name"]}") {
|
||||
val id = list(c["codePoints"]).joinToString("") { String(Character.toChars((it as String).toInt(16))) }.repeat(((c["repeat"] as Long?) ?: 1L).toInt())
|
||||
val bytes = (c["wireHex"] as String?)?.let(::unhex) ?: MessagePackWriter().value(linkedMapOf(
|
||||
"schemaVersion" to WRITE_ADMITTED_VERSION, "messageId" to id, "operation" to "put", "schemaId" to "note.v0", "recordKey" to "note:1",
|
||||
)).toByteArray()
|
||||
val decoded = try { decodeReceiptFrame(parseCultNetMessage(bytes)) } catch (_: IOException) { null }
|
||||
when {
|
||||
c["expect"] == "refuse" -> if (decoded == null) emptyList() else listOf("accepted an id that must be refused (${c["why"]})")
|
||||
decoded == null -> listOf("refused an id that must be accepted (${c["why"]})")
|
||||
decoded.toWireMap()["messageId"] != id -> listOf("id not echoed raw: ${decoded.toWireMap()["messageId"]}")
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (case in list(vectors["schemaInvalid"])) {
|
||||
val c = obj(case)
|
||||
check("schemaInvalid/${c["name"]}") {
|
||||
|
|
@ -129,8 +149,9 @@ fun replayWriteReceiptVectors(path: String): Boolean {
|
|||
val loaded = mapOf(
|
||||
"wire" to list(vectors["wire"]).size, "schemaInvalid" to list(vectors["schemaInvalid"]).size,
|
||||
"contentHashCases" to list(obj(vectors["contentHash"])["cases"]).size, "scenarioCases" to list(obj(vectors["scenarios"])["cases"]).size,
|
||||
"admittedMessageIdCases" to list(obj(vectors["admittedMessageId"])["cases"]).size,
|
||||
)
|
||||
val executed = mapOf("wire" to ran("wire/"), "schemaInvalid" to ran("schemaInvalid/"), "contentHashCases" to ran("contentHash/"), "scenarioCases" to ran("scenario/"))
|
||||
val executed = mapOf("wire" to ran("wire/"), "schemaInvalid" to ran("schemaInvalid/"), "contentHashCases" to ran("contentHash/"), "scenarioCases" to ran("scenario/"), "admittedMessageIdCases" to ran("admittedMessageId/"))
|
||||
loaded.keys.filter { loaded[it] != (declared[it] as Long).toInt() || executed[it] != loaded[it] }
|
||||
.map { "$it: declared ${declared[it]}, loaded ${loaded[it]}, ran ${executed[it]}" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class WriteReceiptVectorTests(unittest.TestCase):
|
|||
def test_vector_sections_are_the_ones_this_file_accounts_for(self) -> None:
|
||||
self.assertEqual(
|
||||
set(_VECTORS) - {"provenance", "specification"},
|
||||
{"constants", "contentHash", "dedupe", "wire", "blankFields", "schemaInvalid", "scenarios", "counts"},
|
||||
{"constants", "contentHash", "dedupe", "wire", "blankFields", "schemaInvalid", "scenarios", "counts", "admittedMessageId"},
|
||||
)
|
||||
self.assertEqual(len(_VECTORS["contentHash"]["cases"]), 4)
|
||||
self.assertEqual(len(_VECTORS["wire"]), 7)
|
||||
|
|
@ -68,6 +68,31 @@ class WriteReceiptVectorTests(unittest.TestCase):
|
|||
self.assertEqual(counts["schemaInvalid"], len(_VECTORS["schemaInvalid"]))
|
||||
self.assertEqual(counts["contentHashCases"], len(_VECTORS["contentHash"]["cases"]))
|
||||
self.assertEqual(counts["scenarioCases"], len(_VECTORS["scenarios"]["cases"]))
|
||||
self.assertEqual(counts["admittedMessageIdCases"], len(_VECTORS["admittedMessageId"]["cases"]))
|
||||
|
||||
def test_admitted_message_id_vectors_are_refused_or_accepted_raw(self) -> None:
|
||||
# `admittedMessageId`: a write_admitted messageId needs a letter or number and no Default_Ignorable code point,
|
||||
# compared raw (never trimmed). A lone surrogate is not valid UTF-8, so its vector carries the whole frame.
|
||||
cases = _VECTORS["admittedMessageId"]["cases"]
|
||||
failures = []
|
||||
for case in cases:
|
||||
raw = (
|
||||
bytes.fromhex(case["wireHex"])
|
||||
if "wireHex" in case
|
||||
else _pack(_admitted(messageId="".join(chr(int(cp, 16)) for cp in case["codePoints"]) * case.get("repeat", 1)))
|
||||
)
|
||||
try:
|
||||
message = parse_message(raw)
|
||||
except Exception: # noqa: BLE001 - any refusal counts
|
||||
refused = True
|
||||
else:
|
||||
refused = False
|
||||
wanted = "".join(chr(int(cp, 16)) for cp in case["codePoints"]) * case.get("repeat", 1)
|
||||
if message.body["messageId"] != wanted:
|
||||
failures.append(f"{case['name']}: id not echoed raw")
|
||||
if refused != (case["expect"] == "refuse"):
|
||||
failures.append(f"{case['name']}: expected {case['expect']}")
|
||||
self.assertEqual(failures, [])
|
||||
|
||||
def test_content_hash_matches_every_vector(self) -> None:
|
||||
for case in _VECTORS["contentHash"]["cases"]:
|
||||
|
|
|
|||
|
|
@ -153,3 +153,51 @@ fn error_v0_refuses_the_v1_only_limit_reached_code() {
|
|||
as_v0[at + 15] = b'0';
|
||||
assert!(decode_cultnet_message_from_slice(&as_v0, CultNetWireContract::CultNetSchemaV0).is_err());
|
||||
}
|
||||
|
||||
/// Vectors `admittedMessageId`: a write_admitted messageId needs a letter or number and no Default_Ignorable code
|
||||
/// point, compared raw (never trimmed). The frame is the spec's `write_admitted_put` bytes with the id swapped in; a
|
||||
/// case with `wireHex` (the lone surrogate, invalid UTF-8) supplies the whole frame.
|
||||
#[test]
|
||||
fn write_admitted_message_id_vectors_are_refused_or_accepted_raw() {
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../contracts/cultnet/interop/write-receipt-vectors.json");
|
||||
let vectors: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
|
||||
let cases = vectors["admittedMessageId"]["cases"].as_array().unwrap();
|
||||
assert_eq!(vectors["counts"]["admittedMessageIdCases"].as_u64().unwrap() as usize, cases.len());
|
||||
let template = unhex("85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31");
|
||||
let old_id_at = template.windows(5).position(|w| w == b"put-1").unwrap() - 1;
|
||||
let mut failures = Vec::new();
|
||||
for case in cases {
|
||||
let name = case["name"].as_str().unwrap();
|
||||
let id: String = case["codePoints"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|cp| char::from_u32(u32::from_str_radix(cp.as_str().unwrap(), 16).unwrap()))
|
||||
.collect::<String>()
|
||||
.repeat(case["repeat"].as_u64().unwrap_or(1) as usize);
|
||||
let frame = match case["wireHex"].as_str() {
|
||||
Some(hex) => unhex(hex),
|
||||
None => {
|
||||
let len = id.len();
|
||||
let header: Vec<u8> = if len < 32 {
|
||||
vec![0xA0 | len as u8]
|
||||
} else if len < 256 {
|
||||
vec![0xD9, len as u8]
|
||||
} else {
|
||||
vec![0xDA, (len >> 8) as u8, len as u8]
|
||||
};
|
||||
[&template[..old_id_at], &header[..], id.as_bytes(), &template[old_id_at + 6..]].concat()
|
||||
}
|
||||
};
|
||||
let decoded = decode_cultnet_message_from_slice(&frame, CultNetWireContract::CultNetSchemaV0);
|
||||
match (case["expect"].as_str().unwrap(), decoded) {
|
||||
("refuse", Ok(_)) => failures.push(format!("{name}: expected refuse")),
|
||||
("accept", Err(error)) => failures.push(format!("{name}: expected accept, got {error}")),
|
||||
("accept", Ok(CultNetMessage::WriteAdmitted { message_id, .. })) if message_id != id => {
|
||||
failures.push(format!("{name}: id not echoed raw"))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} failing:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -280,3 +280,24 @@ test("a blank messageId, record key or tag is exactly Unicode White_Space, not t
|
|||
parseCultNetMessage(put({ tags: [named] }));
|
||||
}
|
||||
});
|
||||
|
||||
// `admittedMessageId` vectors: a write_admitted messageId needs a letter or number and no Default_Ignorable code point,
|
||||
// compared raw (never trimmed). JavaScript strings hold a lone surrogate, so the D800 vector is built from its code point.
|
||||
test("write_admitted messageId: every admittedMessageId vector is refused or accepted raw", () => {
|
||||
const vectorsPath = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json");
|
||||
const vectors = JSON.parse(readFileSync(vectorsPath, "utf8"));
|
||||
assert.equal(vectors.counts.admittedMessageIdCases, vectors.admittedMessageId.cases.length);
|
||||
const failures: string[] = [];
|
||||
for (const vector of vectors.admittedMessageId.cases as { name: string; codePoints: string[]; repeat?: number; expect: "refuse" | "accept" }[]) {
|
||||
const id = vector.codePoints.map((cp) => String.fromCodePoint(parseInt(cp, 16))).join("").repeat(vector.repeat ?? 1);
|
||||
let decodedId: string | undefined;
|
||||
try {
|
||||
decodedId = (parseCultNetMessage(admitted(id)) as CultNetWriteAdmittedMessage).messageId;
|
||||
} catch {
|
||||
decodedId = undefined;
|
||||
}
|
||||
if ((decodedId === undefined) !== (vector.expect === "refuse")) failures.push(`${vector.name}: expected ${vector.expect}`);
|
||||
else if (decodedId !== undefined && decodedId !== id) failures.push(`${vector.name}: id not echoed raw`);
|
||||
}
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Networking.Tests
|
||||
{
|
||||
// contracts/cultnet/interop/write-receipt-vectors.json `admittedMessageId`: a write_admitted messageId needs a letter or
|
||||
// number and no Default_Ignorable code point, compared raw (never trimmed). The frame is built with this runtime's own
|
||||
// encoder; the case with `wireHex` (a lone surrogate is invalid UTF-8) supplies the whole frame.
|
||||
public sealed class CultNetWriteAdmittedMessageIdTests
|
||||
{
|
||||
private static JsonElement Vectors()
|
||||
{
|
||||
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
|
||||
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
|
||||
directory = directory.Parent;
|
||||
var path = Path.Combine(directory!.FullName, "contracts", "cultnet", "interop", "write-receipt-vectors.json");
|
||||
return JsonDocument.Parse(File.ReadAllText(path)).RootElement.Clone();
|
||||
}
|
||||
|
||||
public static IEnumerable<string> Cases() =>
|
||||
Vectors().GetProperty("admittedMessageId").GetProperty("cases").EnumerateArray().Select(c => c.GetProperty("name").GetString()!);
|
||||
|
||||
[Test]
|
||||
public void TheFileDeclaresHowManyCasesThisRunnerMustRun() =>
|
||||
Assert.That(Cases().Count(), Is.EqualTo(Vectors().GetProperty("counts").GetProperty("admittedMessageIdCases").GetInt32()));
|
||||
|
||||
[TestCaseSource(nameof(Cases))]
|
||||
public void AWriteAdmittedMessageIdIsRefusedOrAcceptedRaw(string name)
|
||||
{
|
||||
var vector = Vectors().GetProperty("admittedMessageId").GetProperty("cases").EnumerateArray()
|
||||
.Single(c => c.GetProperty("name").GetString() == name);
|
||||
var id = string.Concat(Enumerable.Repeat(
|
||||
string.Concat(vector.GetProperty("codePoints").EnumerateArray()
|
||||
.Select(cp => char.ConvertFromUtf32(Convert.ToInt32(cp.GetString(), 16)))),
|
||||
vector.TryGetProperty("repeat", out var repeat) ? repeat.GetInt32() : 1));
|
||||
var bytes = vector.TryGetProperty("wireHex", out var wire)
|
||||
? Convert.FromHexString(wire.GetString()!)
|
||||
: CultNetSchemaMessageSerialization.Serialize(new CultNetWriteAdmittedMessage
|
||||
{ MessageId = id, Operation = "put", SchemaId = "note.v0", RecordKey = "note:1" });
|
||||
|
||||
CultNetWriteAdmittedMessage? decoded = null;
|
||||
try { decoded = (CultNetWriteAdmittedMessage)CultNetSchemaMessageSerialization.Deserialize(bytes); }
|
||||
catch (Exception) { /* a refusal */ }
|
||||
|
||||
if (vector.GetProperty("expect").GetString() == "refuse")
|
||||
Assert.That(decoded, Is.Null, vector.GetProperty("why").GetString());
|
||||
else
|
||||
{
|
||||
Assert.That(decoded, Is.Not.Null, vector.GetProperty("why").GetString());
|
||||
Assert.That(decoded!.MessageId, Is.EqualTo(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue