use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
mod base64_serde {
use base64::prelude::{Engine as _, BASE64_STANDARD};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&BASE64_STANDARD.encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
BASE64_STANDARD.decode(&s).map_err(serde::de::Error::custom)
}
}
mod hex_serde {
use serde::Serializer;
use std::fmt::Write as _;
pub fn serialize<S>(bytes: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let hex = bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
let _ = write!(acc, "{b:02x}");
acc
});
serializer.serialize_str(&hex)
}
}
macro_rules! define_event {
(
$(#[$attr:meta])*
$name:ident
) => {
$(#[$attr])*
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct $name {
/// Shared event metadata (timestamp, raw bytes, hash).
metadata: EventMetadata,
payload: serde_json::Value,
}
impl $name {
pub fn new(
metadata: EventMetadata,
payload: serde_json::Value,
) -> Self {
Self { metadata, payload }
}
pub fn metadata(&self) -> &EventMetadata {
&self.metadata
}
pub fn payload(&self) -> &serde_json::Value {
&self.payload
}
}
};
}
macro_rules! delegate_to_inner {
($self:expr, $method:ident) => {
match $self {
Self::GameState(e) => e.$method(),
Self::ClientAction(e) => e.$method(),
Self::MatchState(e) => e.$method(),
Self::DraftBot(e) => e.$method(),
Self::DraftHuman(e) => e.$method(),
Self::DraftComplete(e) => e.$method(),
Self::EventLifecycle(e) => e.$method(),
Self::Session(e) => e.$method(),
Self::Rank(e) => e.$method(),
Self::DeckCollection(e) => e.$method(),
Self::Inventory(e) => e.$method(),
Self::DeckSubmission(e) => e.$method(),
Self::GameResult(e) => e.$method(),
Self::LogFileRotated(e) => e.$method(),
Self::DetailedLoggingStatus(e) => e.$method(),
Self::MatchConnectionState(e) => e.$method(),
Self::TcpConnectionClose(e) => e.$method(),
Self::WebSocketClosed(e) => e.$method(),
Self::ConnectionError(e) => e.$method(),
Self::Truncation(e) => e.$method(),
}
};
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum GameEvent {
GameState(GameStateEvent),
ClientAction(ClientActionEvent),
MatchState(MatchStateEvent),
DraftBot(DraftBotEvent),
DraftHuman(DraftHumanEvent),
DraftComplete(DraftCompleteEvent),
EventLifecycle(EventLifecycleEvent),
Session(SessionEvent),
Rank(RankEvent),
DeckCollection(DeckCollectionEvent),
Inventory(InventoryEvent),
DeckSubmission(DeckSubmissionEvent),
GameResult(GameResultEvent),
LogFileRotated(LogFileRotatedEvent),
DetailedLoggingStatus(DetailedLoggingStatusEvent),
MatchConnectionState(MatchConnectionStateEvent),
TcpConnectionClose(TcpConnectionCloseEvent),
WebSocketClosed(WebSocketClosedEvent),
ConnectionError(ConnectionErrorEvent),
Truncation(TruncationEvent),
}
impl GameEvent {
pub fn performance_class(&self) -> PerformanceClass {
match self {
Self::GameState(_)
| Self::ClientAction(_)
| Self::MatchState(_)
| Self::LogFileRotated(_)
| Self::DetailedLoggingStatus(_)
| Self::MatchConnectionState(_)
| Self::TcpConnectionClose(_)
| Self::WebSocketClosed(_)
| Self::ConnectionError(_)
| Self::Truncation(_) => PerformanceClass::InteractiveDispatch,
Self::DraftBot(_)
| Self::DraftHuman(_)
| Self::DraftComplete(_)
| Self::EventLifecycle(_)
| Self::Session(_)
| Self::Rank(_)
| Self::DeckCollection(_)
| Self::Inventory(_)
| Self::DeckSubmission(_) => PerformanceClass::DurablePerEvent,
Self::GameResult(_) => PerformanceClass::PostGameBatch,
}
}
pub fn metadata(&self) -> &EventMetadata {
delegate_to_inner!(self, metadata)
}
pub fn payload(&self) -> &serde_json::Value {
delegate_to_inner!(self, payload)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum PerformanceClass {
InteractiveDispatch,
DurablePerEvent,
PostGameBatch,
}
impl PerformanceClass {
pub fn as_class_number(&self) -> u8 {
match self {
Self::InteractiveDispatch => 1,
Self::DurablePerEvent => 2,
Self::PostGameBatch => 3,
}
}
pub fn requires_durable_storage(&self) -> bool {
match self {
Self::InteractiveDispatch => false,
Self::DurablePerEvent | Self::PostGameBatch => true,
}
}
pub fn is_batch_trigger(&self) -> bool {
matches!(self, Self::PostGameBatch)
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct EventMetadata {
timestamp: Option<DateTime<Utc>>,
#[serde(with = "base64_serde")]
raw_bytes: Vec<u8>,
#[serde(with = "hex_serde")]
raw_bytes_hash: [u8; 32],
}
impl EventMetadata {
pub fn new(timestamp: Option<DateTime<Utc>>, raw_bytes: Vec<u8>) -> Self {
let raw_bytes_hash: [u8; 32] = Sha256::digest(&raw_bytes).into();
Self {
timestamp,
raw_bytes,
raw_bytes_hash,
}
}
pub fn timestamp(&self) -> Option<DateTime<Utc>> {
self.timestamp
}
pub fn raw_bytes(&self) -> &[u8] {
&self.raw_bytes
}
pub fn raw_bytes_hash(&self) -> &[u8; 32] {
&self.raw_bytes_hash
}
}
impl<'de> Deserialize<'de> for EventMetadata {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct EventMetadataWire {
timestamp: Option<DateTime<Utc>>,
#[serde(with = "base64_serde")]
raw_bytes: Vec<u8>,
#[serde(default, rename = "raw_bytes_hash")]
_raw_bytes_hash: serde::de::IgnoredAny,
}
let wire = EventMetadataWire::deserialize(deserializer)?;
Ok(EventMetadata::new(wire.timestamp, wire.raw_bytes))
}
}
define_event! {
GameStateEvent
}
define_event! {
ClientActionEvent
}
define_event! {
MatchStateEvent
}
define_event! {
DraftBotEvent
}
define_event! {
DraftHumanEvent
}
define_event! {
DraftCompleteEvent
}
define_event! {
EventLifecycleEvent
}
define_event! {
SessionEvent
}
define_event! {
RankEvent
}
define_event! {
DeckCollectionEvent
}
define_event! {
InventoryEvent
}
define_event! {
DeckSubmissionEvent
}
define_event! {
GameResultEvent
}
define_event! {
LogFileRotatedEvent
}
impl LogFileRotatedEvent {
pub fn for_rotation(timestamp: DateTime<Utc>, previous_file_size: u64) -> Self {
let metadata = EventMetadata::new(Some(timestamp), Vec::new());
let payload = serde_json::json!({ "previous_file_size": previous_file_size });
Self::new(metadata, payload)
}
pub fn previous_file_size(&self) -> Option<u64> {
self.payload()["previous_file_size"].as_u64()
}
}
define_event! {
DetailedLoggingStatusEvent
}
impl DetailedLoggingStatusEvent {
pub fn new_status(timestamp: DateTime<Utc>, enabled: bool) -> Self {
let metadata = EventMetadata::new(Some(timestamp), Vec::new());
let payload = serde_json::json!({ "enabled": enabled });
Self::new(metadata, payload)
}
pub fn enabled(&self) -> Option<bool> {
self.payload()["enabled"].as_bool()
}
}
define_event! {
TruncationEvent
}
impl TruncationEvent {
pub fn new_truncation(
timestamp: Option<DateTime<Utc>>,
object_count: u32,
annotation_count: u32,
) -> Self {
let metadata = EventMetadata::new(timestamp, Vec::new());
let payload = serde_json::json!({
"object_count": object_count,
"annotation_count": annotation_count,
});
Self::new(metadata, payload)
}
pub fn object_count(&self) -> Option<u32> {
self.payload()["object_count"]
.as_u64()
.and_then(|v| u32::try_from(v).ok())
}
pub fn annotation_count(&self) -> Option<u32> {
self.payload()["annotation_count"]
.as_u64()
.and_then(|v| u32::try_from(v).ok())
}
}
define_event! {
MatchConnectionStateEvent
}
define_event! {
TcpConnectionCloseEvent
}
define_event! {
WebSocketClosedEvent
}
define_event! {
ConnectionErrorEvent
}
#[cfg(test)]
mod tests {
use super::*;
use base64::prelude::{Engine as _, BASE64_STANDARD};
use chrono::{Datelike, TimeZone};
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn make_metadata(raw: &[u8]) -> EventMetadata {
let timestamp = Utc
.with_ymd_and_hms(2026, 2, 25, 12, 0, 0)
.single()
.unwrap_or_default();
EventMetadata::new(Some(timestamp), raw.to_vec())
}
fn all_variants() -> Vec<GameEvent> {
let meta = make_metadata(b"test");
let payload = serde_json::json!({});
vec![
GameEvent::GameState(GameStateEvent::new(meta.clone(), payload.clone())),
GameEvent::ClientAction(ClientActionEvent::new(meta.clone(), payload.clone())),
GameEvent::MatchState(MatchStateEvent::new(meta.clone(), payload.clone())),
GameEvent::DraftBot(DraftBotEvent::new(meta.clone(), payload.clone())),
GameEvent::DraftHuman(DraftHumanEvent::new(meta.clone(), payload.clone())),
GameEvent::DraftComplete(DraftCompleteEvent::new(meta.clone(), payload.clone())),
GameEvent::EventLifecycle(EventLifecycleEvent::new(meta.clone(), payload.clone())),
GameEvent::Session(SessionEvent::new(meta.clone(), payload.clone())),
GameEvent::Rank(RankEvent::new(meta.clone(), payload.clone())),
GameEvent::DeckCollection(DeckCollectionEvent::new(meta.clone(), payload.clone())),
GameEvent::Inventory(InventoryEvent::new(meta.clone(), payload.clone())),
GameEvent::DeckSubmission(DeckSubmissionEvent::new(meta.clone(), payload.clone())),
GameEvent::GameResult(GameResultEvent::new(meta.clone(), payload.clone())),
GameEvent::LogFileRotated(LogFileRotatedEvent::new(meta.clone(), payload.clone())),
GameEvent::DetailedLoggingStatus(DetailedLoggingStatusEvent::new(
meta.clone(),
payload.clone(),
)),
GameEvent::MatchConnectionState(MatchConnectionStateEvent::new(
meta.clone(),
payload.clone(),
)),
GameEvent::TcpConnectionClose(TcpConnectionCloseEvent::new(
meta.clone(),
payload.clone(),
)),
GameEvent::WebSocketClosed(WebSocketClosedEvent::new(meta.clone(), payload.clone())),
GameEvent::ConnectionError(ConnectionErrorEvent::new(meta.clone(), payload.clone())),
GameEvent::Truncation(TruncationEvent::new(meta.clone(), payload.clone())),
]
}
#[test]
fn test_event_metadata_new_stores_raw_bytes() {
let raw = b"[UnityCrossThreadLogger]some log line";
let meta = make_metadata(raw);
assert_eq!(meta.raw_bytes(), raw);
}
#[test]
fn test_event_metadata_new_computes_raw_bytes_hash() {
let raw = b"test payload";
let meta = make_metadata(raw);
let expected: [u8; 32] = Sha256::digest(raw).into();
assert_eq!(*meta.raw_bytes_hash(), expected);
}
#[test]
fn test_event_metadata_new_stores_timestamp() {
let meta = make_metadata(b"data");
let ts = meta.timestamp();
assert!(ts.is_some());
let ts = ts.unwrap_or_default();
assert_eq!(ts.year(), 2026);
assert_eq!(ts.month(), 2);
}
#[test]
fn test_event_metadata_new_enforces_hash_invariant() {
let raw = b"important data";
let meta = make_metadata(raw);
let expected: [u8; 32] = Sha256::digest(raw).into();
assert_eq!(
*meta.raw_bytes_hash(),
expected,
"raw_bytes_hash must always be SHA-256 of raw_bytes"
);
}
#[test]
fn test_different_raw_bytes_produce_different_hashes() {
let meta1 = make_metadata(b"payload one");
let meta2 = make_metadata(b"payload two");
assert_ne!(meta1.raw_bytes_hash(), meta2.raw_bytes_hash());
}
#[test]
fn test_identical_raw_bytes_produce_same_hash() {
let meta1 = make_metadata(b"same payload");
let meta2 = make_metadata(b"same payload");
assert_eq!(meta1.raw_bytes_hash(), meta2.raw_bytes_hash());
}
#[test]
fn test_empty_raw_bytes_valid() {
let meta = make_metadata(b"");
assert!(meta.raw_bytes().is_empty());
let expected: [u8; 32] = Sha256::digest(b"").into();
assert_eq!(*meta.raw_bytes_hash(), expected);
}
#[test]
fn test_event_metadata_clone_is_equal() {
let meta = make_metadata(b"original");
let cloned = meta.clone();
assert_eq!(meta, cloned);
}
#[test]
fn test_event_metadata_timestamp_getter() {
let meta = make_metadata(b"data");
let ts = meta.timestamp();
assert!(ts.is_some());
let ts = ts.unwrap_or_default();
assert_eq!(ts.year(), 2026);
assert_eq!(ts.month(), 2);
assert_eq!(ts.day(), 25);
}
#[test]
fn test_event_metadata_none_timestamp() {
let meta = EventMetadata::new(None, b"data".to_vec());
assert!(meta.timestamp().is_none());
}
#[test]
fn test_game_state_event_field_access() {
let event = GameStateEvent::new(
make_metadata(b"gre payload"),
serde_json::json!({"type": "GameStateMessage"}),
);
assert_eq!(event.payload()["type"], "GameStateMessage");
assert_eq!(event.metadata().raw_bytes(), b"gre payload");
}
#[test]
fn test_client_action_event_field_access() {
let event = ClientActionEvent::new(
make_metadata(b"client action"),
serde_json::json!({"type": "MulliganResp"}),
);
assert_eq!(event.payload()["type"], "MulliganResp");
}
#[test]
fn test_match_state_event_field_access() {
let event = MatchStateEvent::new(
make_metadata(b"match state"),
serde_json::json!(
{"matchGameRoomStateChangedEvent": {}}
),
);
assert!(event.payload()["matchGameRoomStateChangedEvent"].is_object());
}
#[test]
fn test_draft_bot_event_field_access() {
let event = DraftBotEvent::new(
make_metadata(b"bot draft"),
serde_json::json!({"DraftStatus": "PickNext"}),
);
assert_eq!(event.payload()["DraftStatus"], "PickNext");
}
#[test]
fn test_draft_human_event_field_access() {
let event = DraftHumanEvent::new(
make_metadata(b"human draft"),
serde_json::json!({"draft_id": "test-draft-123"}),
);
assert_eq!(event.payload()["draft_id"], "test-draft-123");
}
#[test]
fn test_draft_complete_event_field_access() {
let event = DraftCompleteEvent::new(
make_metadata(b"draft complete"),
serde_json::json!({"Draft_CompleteDraft": true}),
);
assert_eq!(
event.payload()["Draft_CompleteDraft"],
serde_json::json!(true)
);
}
#[test]
fn test_event_lifecycle_event_field_access() {
let event = EventLifecycleEvent::new(
make_metadata(b"event lifecycle"),
serde_json::json!({"action": "Event_Join"}),
);
assert_eq!(event.payload()["action"], "Event_Join");
}
#[test]
fn test_session_event_field_access() {
let event = SessionEvent::new(
make_metadata(b"session data"),
serde_json::json!({"DisplayName": "Player"}),
);
assert_eq!(event.payload()["DisplayName"], "Player");
}
#[test]
fn test_rank_event_field_access() {
let event = RankEvent::new(
make_metadata(b"rank data"),
serde_json::json!(
{"constructedClass": "Gold", "constructedLevel": 2}
),
);
assert_eq!(event.payload()["constructedClass"], "Gold");
}
#[test]
fn test_deck_collection_event_field_access() {
let event = DeckCollectionEvent::new(
make_metadata(b"deck collection"),
serde_json::json!({
"type": "deck_collection_snapshot",
"decks": {
"deck-1": {
"DeckId": "deck-1",
"Name": "Reanimator",
"list": {"MainDeck": [{"cardId": 1, "quantity": 4}]}
}
}
}),
);
assert_eq!(event.payload()["decks"]["deck-1"]["DeckId"], "deck-1");
}
#[test]
fn test_inventory_event_field_access() {
let event = InventoryEvent::new(
make_metadata(b"inventory"),
serde_json::json!(
{"gold": 5000, "gems": 200, "wcCommon": 10}
),
);
assert_eq!(event.payload()["gold"], 5000);
}
#[test]
fn test_game_result_event_field_access() {
let event = GameResultEvent::new(
make_metadata(b"game result"),
serde_json::json!(
{"WinningType": "Win", "GameStage": "GameOver"}
),
);
assert_eq!(event.payload()["WinningType"], "Win");
}
#[test]
fn test_game_event_all_variants_have_correct_performance_class() {
let events = all_variants();
let expected_classes = [
PerformanceClass::InteractiveDispatch, PerformanceClass::InteractiveDispatch, PerformanceClass::InteractiveDispatch, PerformanceClass::DurablePerEvent, PerformanceClass::DurablePerEvent, PerformanceClass::DurablePerEvent, PerformanceClass::DurablePerEvent, PerformanceClass::DurablePerEvent, PerformanceClass::DurablePerEvent, PerformanceClass::DurablePerEvent, PerformanceClass::DurablePerEvent, PerformanceClass::DurablePerEvent, PerformanceClass::PostGameBatch, PerformanceClass::InteractiveDispatch, PerformanceClass::InteractiveDispatch, PerformanceClass::InteractiveDispatch, PerformanceClass::InteractiveDispatch, PerformanceClass::InteractiveDispatch, PerformanceClass::InteractiveDispatch, PerformanceClass::InteractiveDispatch, ];
assert_eq!(
events.len(),
expected_classes.len(),
"all_variants() and expected_classes must have the same length"
);
for (event, expected) in events.iter().zip(expected_classes.iter()) {
assert_eq!(&event.performance_class(), expected);
}
}
#[test]
fn test_game_event_metadata_accessor_all_variants() {
let raw = b"test";
let events = all_variants();
for event in &events {
assert_eq!(event.metadata().raw_bytes(), raw);
}
}
#[test]
fn test_game_event_payload_accessor_all_variants() {
let events = all_variants();
let expected = serde_json::json!({});
for event in &events {
assert_eq!(*event.payload(), expected);
}
}
#[test]
fn test_performance_class_equality() {
assert_eq!(
PerformanceClass::InteractiveDispatch,
PerformanceClass::InteractiveDispatch
);
assert_ne!(
PerformanceClass::InteractiveDispatch,
PerformanceClass::DurablePerEvent
);
assert_ne!(
PerformanceClass::DurablePerEvent,
PerformanceClass::PostGameBatch
);
}
#[test]
fn test_performance_class_as_class_number_interactive_dispatch_returns_1() {
assert_eq!(PerformanceClass::InteractiveDispatch.as_class_number(), 1);
}
#[test]
fn test_performance_class_as_class_number_durable_per_event_returns_2() {
assert_eq!(PerformanceClass::DurablePerEvent.as_class_number(), 2);
}
#[test]
fn test_performance_class_as_class_number_post_game_batch_returns_3() {
assert_eq!(PerformanceClass::PostGameBatch.as_class_number(), 3);
}
#[test]
fn test_performance_class_requires_durable_storage_class1_false() {
assert!(!PerformanceClass::InteractiveDispatch.requires_durable_storage());
}
#[test]
fn test_performance_class_requires_durable_storage_class2_true() {
assert!(PerformanceClass::DurablePerEvent.requires_durable_storage());
}
#[test]
fn test_performance_class_requires_durable_storage_class3_true() {
assert!(PerformanceClass::PostGameBatch.requires_durable_storage());
}
#[test]
fn test_performance_class_is_batch_trigger_class1_false() {
assert!(!PerformanceClass::InteractiveDispatch.is_batch_trigger());
}
#[test]
fn test_performance_class_is_batch_trigger_class2_false() {
assert!(!PerformanceClass::DurablePerEvent.is_batch_trigger());
}
#[test]
fn test_performance_class_is_batch_trigger_class3_true() {
assert!(PerformanceClass::PostGameBatch.is_batch_trigger());
}
#[test]
fn test_performance_class_class_number_matches_event_mapping() {
let events = all_variants();
let expected_numbers: Vec<u8> = vec![
1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 1, 1, 1, 1, 1, 1, 1, ];
assert_eq!(events.len(), expected_numbers.len());
for (event, expected_num) in events.iter().zip(expected_numbers.iter()) {
assert_eq!(event.performance_class().as_class_number(), *expected_num);
}
}
#[test]
fn test_game_event_serde_round_trip_all_variants() -> TestResult {
for event in all_variants() {
let serialized = serde_json::to_string(&event)?;
let deserialized: GameEvent = serde_json::from_str(&serialized)?;
assert_eq!(deserialized, event);
}
Ok(())
}
#[test]
fn test_event_metadata_serde_round_trip() -> TestResult {
let meta = make_metadata(b"round trip test");
let serialized = serde_json::to_string(&meta)?;
let deserialized: EventMetadata = serde_json::from_str(&serialized)?;
assert_eq!(deserialized, meta);
Ok(())
}
#[test]
fn test_event_metadata_deserialize_recomputes_hash() -> TestResult {
let meta = make_metadata(b"test data");
let mut serialized: serde_json::Value = serde_json::to_value(&meta)?;
serialized["raw_bytes_hash"] = serde_json::json!("00".repeat(32));
let deserialized: EventMetadata = serde_json::from_value(serialized)?;
assert_eq!(*deserialized.raw_bytes_hash(), *meta.raw_bytes_hash());
assert_eq!(deserialized.raw_bytes(), meta.raw_bytes());
Ok(())
}
#[test]
fn test_performance_class_serde_round_trip() -> TestResult {
for class in [
PerformanceClass::InteractiveDispatch,
PerformanceClass::DurablePerEvent,
PerformanceClass::PostGameBatch,
] {
let serialized = serde_json::to_string(&class)?;
let deserialized: PerformanceClass = serde_json::from_str(&serialized)?;
assert_eq!(deserialized, class);
}
Ok(())
}
#[test]
fn test_event_metadata_serializes_raw_bytes_as_base64() -> TestResult {
let meta = make_metadata(b"hello world");
let serialized: serde_json::Value = serde_json::to_value(&meta)?;
assert_eq!(serialized["raw_bytes"], "aGVsbG8gd29ybGQ=");
Ok(())
}
#[test]
fn test_event_metadata_serializes_raw_bytes_hash_as_hex() -> TestResult {
let meta = make_metadata(b"hello world");
let serialized: serde_json::Value = serde_json::to_value(&meta)?;
let hash_str = serialized["raw_bytes_hash"]
.as_str()
.ok_or("raw_bytes_hash should be a string")?;
assert_eq!(
hash_str,
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
Ok(())
}
#[test]
fn test_event_metadata_deserialize_missing_raw_bytes_hash() -> TestResult {
let json = serde_json::json!({
"timestamp": "2026-02-25T12:00:00Z",
"raw_bytes": BASE64_STANDARD.encode(b"test data"),
});
let meta: EventMetadata = serde_json::from_value(json)?;
let expected: [u8; 32] = Sha256::digest(b"test data").into();
assert_eq!(*meta.raw_bytes_hash(), expected);
assert_eq!(meta.raw_bytes(), b"test data");
Ok(())
}
#[test]
fn test_event_metadata_deserialize_integer_array_raw_bytes_hash() -> TestResult {
let json = serde_json::json!({
"timestamp": "2026-02-25T12:00:00Z",
"raw_bytes": BASE64_STANDARD.encode(b"data"),
"raw_bytes_hash": vec![0; 32],
});
let meta: EventMetadata = serde_json::from_value(json)?;
let expected: [u8; 32] = Sha256::digest(b"data").into();
assert_eq!(*meta.raw_bytes_hash(), expected);
Ok(())
}
#[test]
fn test_event_metadata_none_timestamp_serde_round_trip() -> TestResult {
let meta = EventMetadata::new(None, b"no timestamp".to_vec());
let serialized = serde_json::to_string(&meta)?;
let deserialized: EventMetadata = serde_json::from_str(&serialized)?;
assert_eq!(deserialized, meta);
assert!(deserialized.timestamp().is_none());
Ok(())
}
#[test]
fn test_event_metadata_deserialize_null_timestamp() -> TestResult {
let json = serde_json::json!({
"timestamp": null,
"raw_bytes": BASE64_STANDARD.encode(b"data"),
});
let meta: EventMetadata = serde_json::from_value(json)?;
assert!(meta.timestamp().is_none());
assert_eq!(meta.raw_bytes(), b"data");
Ok(())
}
#[test]
fn test_log_file_rotated_for_rotation_stores_previous_file_size() {
let ts = Utc
.with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
.single()
.unwrap_or_default();
let event = LogFileRotatedEvent::for_rotation(ts, 42_000);
assert_eq!(event.previous_file_size(), Some(42_000));
}
#[test]
fn test_log_file_rotated_for_rotation_stores_timestamp() {
let ts = Utc
.with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
.single()
.unwrap_or_default();
let event = LogFileRotatedEvent::for_rotation(ts, 1000);
assert_eq!(event.metadata().timestamp(), Some(ts));
}
#[test]
fn test_log_file_rotated_has_empty_raw_bytes() {
let ts = Utc
.with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
.single()
.unwrap_or_default();
let event = LogFileRotatedEvent::for_rotation(ts, 500);
assert!(event.metadata().raw_bytes().is_empty());
}
#[test]
fn test_log_file_rotated_serde_round_trip() -> TestResult {
let ts = Utc
.with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
.single()
.unwrap_or_default();
let event = GameEvent::LogFileRotated(LogFileRotatedEvent::for_rotation(ts, 12345));
let serialized = serde_json::to_string(&event)?;
let deserialized: GameEvent = serde_json::from_str(&serialized)?;
assert_eq!(deserialized, event);
Ok(())
}
#[test]
fn test_log_file_rotated_performance_class_is_interactive() {
let ts = Utc
.with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
.single()
.unwrap_or_default();
let event = GameEvent::LogFileRotated(LogFileRotatedEvent::for_rotation(ts, 0));
assert_eq!(
event.performance_class(),
PerformanceClass::InteractiveDispatch
);
}
#[test]
fn test_detailed_logging_status_new_status_stores_enabled_true() {
let ts = Utc
.with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
.single()
.unwrap_or_default();
let event = DetailedLoggingStatusEvent::new_status(ts, true);
assert_eq!(event.enabled(), Some(true));
}
#[test]
fn test_detailed_logging_status_new_status_stores_enabled_false() {
let ts = Utc
.with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
.single()
.unwrap_or_default();
let event = DetailedLoggingStatusEvent::new_status(ts, false);
assert_eq!(event.enabled(), Some(false));
}
#[test]
fn test_detailed_logging_status_stores_timestamp() {
let ts = Utc
.with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
.single()
.unwrap_or_default();
let event = DetailedLoggingStatusEvent::new_status(ts, true);
assert_eq!(event.metadata().timestamp(), Some(ts));
}
#[test]
fn test_detailed_logging_status_has_empty_raw_bytes() {
let ts = Utc
.with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
.single()
.unwrap_or_default();
let event = DetailedLoggingStatusEvent::new_status(ts, false);
assert!(event.metadata().raw_bytes().is_empty());
}
#[test]
fn test_detailed_logging_status_serde_round_trip() -> TestResult {
let ts = Utc
.with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
.single()
.unwrap_or_default();
let event =
GameEvent::DetailedLoggingStatus(DetailedLoggingStatusEvent::new_status(ts, false));
let serialized = serde_json::to_string(&event)?;
let deserialized: GameEvent = serde_json::from_str(&serialized)?;
assert_eq!(deserialized, event);
Ok(())
}
#[test]
fn test_detailed_logging_status_performance_class_is_interactive() {
let ts = Utc
.with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
.single()
.unwrap_or_default();
let event =
GameEvent::DetailedLoggingStatus(DetailedLoggingStatusEvent::new_status(ts, true));
assert_eq!(
event.performance_class(),
PerformanceClass::InteractiveDispatch
);
}
fn make_truncation(object_count: u32, annotation_count: u32) -> TruncationEvent {
let ts = Utc
.with_ymd_and_hms(2026, 5, 13, 10, 1, 12)
.single()
.unwrap_or_default();
TruncationEvent::new_truncation(Some(ts), object_count, annotation_count)
}
#[test]
fn test_truncation_event_payload_contains_object_count() {
let event = make_truncation(63, 4);
assert_eq!(event.payload()["object_count"], 63);
}
#[test]
fn test_truncation_event_payload_contains_annotation_count() {
let event = make_truncation(63, 4);
assert_eq!(event.payload()["annotation_count"], 4);
}
#[test]
fn test_truncation_event_metadata_has_empty_raw_bytes() {
let event = make_truncation(63, 4);
assert!(event.metadata().raw_bytes().is_empty());
}
#[test]
fn test_truncation_event_metadata_carries_timestamp() {
let event = make_truncation(63, 4);
assert!(event.metadata().timestamp().is_some());
}
#[test]
fn test_truncation_event_accepts_missing_timestamp() {
let event = TruncationEvent::new_truncation(None, 51, 0);
assert!(event.metadata().timestamp().is_none());
assert_eq!(event.object_count(), Some(51));
}
#[test]
fn test_truncation_event_object_count_accessor() {
assert_eq!(make_truncation(63, 4).object_count(), Some(63));
}
#[test]
fn test_truncation_event_annotation_count_accessor() {
assert_eq!(make_truncation(63, 4).annotation_count(), Some(4));
}
#[test]
fn test_truncation_event_performance_class_is_interactive() {
let event = GameEvent::Truncation(make_truncation(63, 4));
assert_eq!(
event.performance_class(),
PerformanceClass::InteractiveDispatch
);
}
#[test]
fn test_truncation_event_serialize_round_trip() -> TestResult {
let event = GameEvent::Truncation(make_truncation(63, 4));
let serialized = serde_json::to_string(&event)?;
let deserialized: GameEvent = serde_json::from_str(&serialized)?;
assert_eq!(deserialized, event);
Ok(())
}
}