CultLib/packages/cultnet-rs/tests/error_message.rs
Meta Vens 3f942a57e7 Cut 4 Soul 4d: a skipped entry is relayed as the primary sent it; probes, wire cases and the closed-enum rule committed
F1: RecordUnappliedEntry keeps the primary's wire entry on the in-memory log
entry (internal Relayed) and ToLogEntryMessage returns it through the same
normalisation the store uses, so a replica with no store no longer relays a
skipped put as a delete; memory and disk relay identically.
Soul's 4d probes are committed (chained replica keeps the row, key-prefixed
reads serve only owned keys, primary-all serves everything; the two
measurements are explicit) with a memory/disk relay-parity test.
Rust: captured-bytes cases for unowned_schema and variant_unsupported (with and
without a subscription).
The error-code set is stated as a closed enum in the schema description (both
copies) and in cultnet-distributed-database.md: adding a code is one
coordinated change across runtimes, since Rust and TypeScript refuse an unknown
code. The Soul 4c probe file's header says what it guards.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 08:03:09 +02:00

198 lines
11 KiB
Rust

//! R-N (docs/cultnet-selection-cut.md, fix batch 3): `cultnet.error.v0`'s `code`/`details` fields,
//! pinned byte-for-byte against the landed C# reference
//! (`CultNetErrorMessage.ForSelectionInvalid`/`ForCursor`/`ForReferenceOutsideTarget`,
//! `src/GameCult.Networking/CultNetSchemaMessages.cs`, commit `e8a3dec`).
//!
//! The expected bytes below are not hand-derived: they were captured by running MessagePack C#
//! 3.1.7 (the exact package/version `GameCult.Networking` depends on) against a standalone
//! `CultNetErrorMessage`/`CultNetErrorDetails` pair carrying the landed `[Key(...)]` shape, using
//! `MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData)` - the
//! same options `CultNetSchemaMessageSerialization.Options` uses - then
//! `Convert.ToHexString(MessagePackSerializer.Serialize(message, options))`. The probe program lived
//! at `%TEMP%\claude\probe-msgpack` (outside this repo) and was run in the `dotnet` Yggdrasil verify
//! image via `ygg-verify.sh`; it is not part of this crate. Each case below documents the C# call
//! that produced its bytes.
//!
//! This confirms the one thing that matters for byte parity here: MessagePack C#'s
//! `[Key("name")]`-attributed map formatter always writes every declared key, in declaration order,
//! nil for a null value - it never omits a key. `CultNetMessage::Error` is therefore encoded through
//! the same `rmpv::Value::Map`-literal path as the crate's other raw `cultnet.schema.v0` messages
//! (`encode_raw_cultnet_schema_message`/`parse_raw_cultnet_schema_message` in `src/contracts.rs`)
//! rather than through the generic `serde_json::Value` round trip every other `CultNetMessage`
//! variant uses - that path's `serde_json::Value::Object` is a `BTreeMap` (no `preserve_order`
//! feature enabled in this crate's `serde_json` dependency), so it would emit keys sorted
//! alphabetically and silently break this exact byte match.
use cultnet_rs::{
CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetWireContract,
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
};
fn hex_decode(hex: &str) -> Vec<u8> {
assert!(hex.len() % 2 == 0, "odd-length hex string");
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("valid hex byte"))
.collect()
}
struct Case {
name: &'static str,
/// Captured via `Convert.ToHexString(MessagePackSerializer.Serialize(msg, options))` in the
/// probe program described in this file's header comment.
csharp_hex: &'static str,
message: fn() -> CultNetMessage,
}
fn cases() -> Vec<Case> {
vec![
Case {
name: "selection_invalid_schemas_null",
// C#: ForSelectionInvalid(new CultNetSelectionInvalidException("schemas", null,
// "must not be empty"))
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92473656C656374696F6E5F696E76616C69643A206D757374206E6F7420626520656D707479AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A7736368656D6173A576616C7565C0A461734F66C0A763757272656E74C0",
message: || CultNetMessage::Error {
error: "selection_invalid: must not be empty".to_string(),
code: Some(CultNetErrorCode::SelectionInvalid),
details: Some(CultNetErrorDetails {
field: Some("schemas".to_string()),
value: None,
as_of: None,
current: None,
}),
},
},
Case {
name: "selection_invalid_field_value",
// C#: ForSelectionInvalid(new CultNetSelectionInvalidException("keys", " ",
// "blank key entry"))
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92273656C656374696F6E5F696E76616C69643A20626C616E6B206B657920656E747279AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A46B657973A576616C7565A3202020A461734F66C0A763757272656E74C0",
message: || CultNetMessage::Error {
error: "selection_invalid: blank key entry".to_string(),
code: Some(CultNetErrorCode::SelectionInvalid),
details: Some(CultNetErrorDetails {
field: Some("keys".to_string()),
value: Some(" ".to_string()),
as_of: None,
current: None,
}),
},
},
Case {
name: "cursor_stale",
// C#: ForCursor(new CultNetSelectionCursorException("cursor_stale",
// "cursor is stale", asOf: 1, current: 2))
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72BD637572736F725F7374616C653A20637572736F72206973207374616C65AB726F7574696E6748696E74C0A4636F6465AC637572736F725F7374616C65A764657461696C7384A56669656C64C0A576616C7565C0A461734F6601A763757272656E7402",
message: || CultNetMessage::Error {
error: "cursor_stale: cursor is stale".to_string(),
code: Some(CultNetErrorCode::CursorStale),
details: Some(CultNetErrorDetails {
field: None,
value: None,
as_of: Some(1),
current: Some(2),
}),
},
},
Case {
name: "cursor_invalid",
// C#: ForCursor(new CultNetSelectionCursorException("cursor_invalid",
// "cannot parse cursor")) - no asOf/current, so Details stays null.
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D923637572736F725F696E76616C69643A2063616E6E6F7420706172736520637572736F72AB726F7574696E6748696E74C0A4636F6465AE637572736F725F696E76616C6964A764657461696C73C0",
message: || CultNetMessage::Error {
error: "cursor_invalid: cannot parse cursor".to_string(),
code: Some(CultNetErrorCode::CursorInvalid),
details: None,
},
},
Case {
name: "reference_outside_target",
// C#: ForReferenceOutsideTarget(...) - built directly with the final Error text here
// (the constructor path itself double-prefixes "reference_outside_target: ", see this
// crate's SelectionRefusal -> CultNetMessage mapping doc comment in src/selection.rs
// for the discrepancy); the wire shape under test does not depend on the text's content.
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D9317265666572656E63655F6F7574736964655F7461726765743A206F757473696465206465636C6172656420746172676574AB726F7574696E6748696E74C0A4636F6465B87265666572656E63655F6F7574736964655F746172676574A764657461696C73C0",
message: || CultNetMessage::Error {
error: "reference_outside_target: outside declared target".to_string(),
code: Some(CultNetErrorCode::ReferenceOutsideTarget),
details: None,
},
},
Case {
name: "variant_unsupported_subscription_ended",
// C#: ForVariantUnsupported("variant unsupported here", "sub-1")
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C7384A56669656C64AE737562736372697074696F6E4964A576616C7565A57375622D31A461734F66C0A763757272656E74C0",
message: || CultNetMessage::Error {
error: "variant unsupported here".to_string(),
code: Some(CultNetErrorCode::VariantUnsupported),
details: Some(CultNetErrorDetails {
field: Some("subscriptionId".to_string()),
value: Some("sub-1".to_string()),
as_of: None,
current: None,
}),
},
},
Case {
name: "variant_unsupported_no_subscription",
// C#: ForVariantUnsupported("variant unsupported here") - no subscription ended, so Details stays null.
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C73C0",
message: || CultNetMessage::Error {
error: "variant unsupported here".to_string(),
code: Some(CultNetErrorCode::VariantUnsupported),
details: None,
},
},
Case {
name: "unowned_schema",
// C#: ForUnownedSchema(new CultNetUnownedSchemaException("some.schema.v1", new CultRecordKey("some:key")))
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D95C756E6F776E65645F736368656D613A204E6F207368617264206F776E7320736368656D612027736F6D652E736368656D612E763127206B65792027736F6D653A6B6579273B207468652077726974652077617320726566757365642EAB726F7574696E6748696E74C0A4636F6465AE756E6F776E65645F736368656D61A764657461696C7384A56669656C64A8736368656D614964A576616C7565AE736F6D652E736368656D612E7631A461734F66C0A763757272656E74C0",
message: || CultNetMessage::Error {
error: "unowned_schema: No shard owns schema 'some.schema.v1' key 'some:key'; the write was refused.".to_string(),
code: Some(CultNetErrorCode::UnownedSchema),
details: Some(CultNetErrorDetails {
field: Some("schemaId".to_string()),
value: Some("some.schema.v1".to_string()),
as_of: None,
current: None,
}),
},
},
]
}
#[test]
fn rust_encode_matches_csharp_bytes_for_every_r_n_code() {
for case in cases() {
let expected = hex_decode(case.csharp_hex);
let actual = encode_cultnet_message_to_vec(&(case.message)(), CultNetWireContract::CultNetSchemaV0)
.unwrap_or_else(|err| panic!("{}: encode failed: {err}", case.name));
assert_eq!(
actual, expected,
"{}: Rust-encoded bytes do not match the captured C# encoding",
case.name
);
}
}
#[test]
fn rust_decodes_the_csharp_written_bytes_back_into_the_same_typed_message() {
for case in cases() {
let bytes = hex_decode(case.csharp_hex);
let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0)
.unwrap_or_else(|err| panic!("{}: decode failed: {err}", case.name));
assert_eq!(decoded, (case.message)(), "{}: decoded value mismatch", case.name);
}
}
#[test]
fn round_trip_is_stable() {
for case in cases() {
let message = (case.message)();
let encoded = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)
.unwrap_or_else(|err| panic!("{}: encode failed: {err}", case.name));
let decoded = decode_cultnet_message_from_slice(&encoded, CultNetWireContract::CultNetSchemaV0)
.unwrap_or_else(|err| panic!("{}: decode failed: {err}", case.name));
assert_eq!(decoded, message, "{}: round trip mismatch", case.name);
}
}