manasight_parser/events.rs
1//! Public event type enums and structs for parsed MTG Arena log events.
2//!
3//! These types represent the structured output of the parser and form the
4//! contract between the parser library and its consumers. Each event
5//! corresponds to a category in the
6//! [Event-to-Class Mapping][spec].
7//!
8//! [spec]: https://github.com/manasight/manasight-docs/blob/main/docs/requirements/feature-specs/log-file-parser.md#event-to-class-mapping
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14// ---------------------------------------------------------------------------
15// Serde helper modules
16// ---------------------------------------------------------------------------
17
18/// Serialize `Vec<u8>` as a base64 string (RFC 4648 standard alphabet).
19mod base64_serde {
20 use base64::prelude::{Engine as _, BASE64_STANDARD};
21 use serde::{Deserialize, Deserializer, Serializer};
22
23 pub fn serialize<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
24 where
25 S: Serializer,
26 {
27 serializer.serialize_str(&BASE64_STANDARD.encode(bytes))
28 }
29
30 pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
31 where
32 D: Deserializer<'de>,
33 {
34 let s = String::deserialize(deserializer)?;
35 BASE64_STANDARD.decode(&s).map_err(serde::de::Error::custom)
36 }
37}
38
39/// Serialize `[u8; 32]` as a 64-character lowercase hex string.
40///
41/// Serialize-only: `EventMetadata` has a custom `Deserialize` impl that
42/// ignores `raw_bytes_hash` (it is always recomputed from `raw_bytes`), so
43/// no `deserialize` function is needed. If `#[derive(Deserialize)]` is
44/// ever added to `EventMetadata`, add a `deserialize` function here.
45mod hex_serde {
46 use serde::Serializer;
47 use std::fmt::Write as _;
48
49 pub fn serialize<S>(bytes: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
50 where
51 S: Serializer,
52 {
53 let hex = bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
54 // write! to String is infallible.
55 let _ = write!(acc, "{b:02x}");
56 acc
57 });
58 serializer.serialize_str(&hex)
59 }
60}
61
62// ---------------------------------------------------------------------------
63// Macros
64// ---------------------------------------------------------------------------
65
66/// Generates a category-specific event struct with `metadata` and `payload`
67/// fields plus public accessor methods.
68///
69/// When a new event category is added, create a new invocation of this
70/// macro rather than writing the struct and impl by hand.
71macro_rules! define_event {
72 (
73 $(#[$attr:meta])*
74 $name:ident
75 ) => {
76 $(#[$attr])*
77 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78 pub struct $name {
79 /// Shared event metadata (timestamp, raw bytes, hash).
80 metadata: EventMetadata,
81 /// The parsed JSON payload.
82 payload: serde_json::Value,
83 }
84
85 impl $name {
86 /// Constructs a new event with the given metadata and payload.
87 pub fn new(
88 metadata: EventMetadata,
89 payload: serde_json::Value,
90 ) -> Self {
91 Self { metadata, payload }
92 }
93
94 /// Returns the shared event metadata.
95 pub fn metadata(&self) -> &EventMetadata {
96 &self.metadata
97 }
98
99 /// Returns the parsed JSON payload.
100 pub fn payload(&self) -> &serde_json::Value {
101 &self.payload
102 }
103 }
104 };
105}
106
107/// Dispatches a field accessor across all `GameEvent` variants.
108///
109/// When a new variant is added to `GameEvent`, add it here too.
110/// `$method` must be a `&self` no-arg method present on all inner types.
111macro_rules! delegate_to_inner {
112 ($self:expr, $method:ident) => {
113 match $self {
114 Self::GameState(e) => e.$method(),
115 Self::ClientAction(e) => e.$method(),
116 Self::MatchState(e) => e.$method(),
117 Self::DraftBot(e) => e.$method(),
118 Self::DraftHuman(e) => e.$method(),
119 Self::DraftComplete(e) => e.$method(),
120 Self::EventLifecycle(e) => e.$method(),
121 Self::Session(e) => e.$method(),
122 Self::Rank(e) => e.$method(),
123 Self::Collection(e) => e.$method(),
124 Self::Inventory(e) => e.$method(),
125 Self::GameResult(e) => e.$method(),
126 Self::LogFileRotated(e) => e.$method(),
127 Self::DetailedLoggingStatus(e) => e.$method(),
128 Self::MatchConnectionState(e) => e.$method(),
129 Self::TcpConnectionClose(e) => e.$method(),
130 Self::WebSocketClosed(e) => e.$method(),
131 Self::ConnectionError(e) => e.$method(),
132 }
133 };
134}
135
136// ---------------------------------------------------------------------------
137// GameEvent enum
138// ---------------------------------------------------------------------------
139
140/// A parsed MTG Arena log event.
141///
142/// Each variant wraps a category-specific struct containing parsed fields,
143/// the original raw log bytes, and a precomputed payload hash. Consumers
144/// subscribe to the event bus and pattern-match on this enum.
145///
146/// Marked `#[non_exhaustive]` so that new event categories can be added
147/// in future releases without a breaking change for downstream consumers.
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149#[non_exhaustive]
150pub enum GameEvent {
151 /// GRE-to-client messages: `GameStateMessage`, `ConnectResp`,
152 /// `QueuedGameStateMessage`. Class 1 — interactive dispatch.
153 GameState(GameStateEvent),
154
155 /// Client-to-GRE messages: `SelectNResp`, `SubmitDeckResp`,
156 /// `MulliganResp`. Class 1 — interactive dispatch.
157 ClientAction(ClientActionEvent),
158
159 /// Match room state changes (`matchGameRoomStateChangedEvent`).
160 /// Class 1 — interactive dispatch.
161 MatchState(MatchStateEvent),
162
163 /// Bot draft picks (`DraftStatus: "PickNext"`, `BotDraft_DraftPick`).
164 /// Class 2 — durable per-event.
165 DraftBot(DraftBotEvent),
166
167 /// Human draft picks (`Draft.Notify`, `EventPlayerDraftMakePick`).
168 /// Class 2 — durable per-event.
169 DraftHuman(DraftHumanEvent),
170
171 /// Draft completion (`Draft_CompleteDraft`).
172 /// Class 2 — durable per-event.
173 DraftComplete(DraftCompleteEvent),
174
175 /// Event lifecycle: `==> EventJoin`, `==> EventClaimPrize`,
176 /// `==> EventEnterPairing`. Class 2 — durable per-event.
177 EventLifecycle(EventLifecycleEvent),
178
179 /// Session: login, account identity, logout.
180 /// Class 2 — durable per-event.
181 Session(SessionEvent),
182
183 /// Rank snapshot (`<== RankGetCombinedRankInfo`).
184 /// Class 2 — durable per-event.
185 Rank(RankEvent),
186
187 /// Card collection snapshot (`<== StartHook` with `PlayerCards`).
188 /// Class 2 — durable per-event.
189 Collection(CollectionEvent),
190
191 /// Inventory snapshot (`<== StartHook` with `InventoryInfo`):
192 /// currency, wildcards, etc. Class 2 — durable per-event.
193 Inventory(InventoryEvent),
194
195 /// Game result (`GameStage_GameOver` from GRE `GameStateMessage`).
196 /// Class 3 — triggers post-game batch assembly.
197 GameResult(GameResultEvent),
198
199 /// Log file rotation detected — `Player.log` was replaced (MTGA restart).
200 ///
201 /// Emitted by the file tailer when it detects that the log file at the
202 /// monitored path has been replaced (file size shrinkage or mtime jump).
203 /// Downstream consumers should reset their state for a new session.
204 /// Class 1 — interactive dispatch (local reset signal).
205 LogFileRotated(LogFileRotatedEvent),
206
207 /// Detailed logging status change detected.
208 ///
209 /// Emitted by the file tailer when it determines whether Arena's
210 /// "Detailed Logs (Plugin Support)" setting is enabled. `enabled: false`
211 /// is emitted after 30 seconds of observed log writes without any
212 /// `[UnityCrossThreadLogger]` or `[Client GRE]` headers. `enabled: true`
213 /// is emitted if structured headers are later detected (user enabled the
214 /// setting and restarted Arena).
215 /// Class 1 — interactive dispatch (local status signal).
216 DetailedLoggingStatus(DetailedLoggingStatusEvent),
217
218 /// Match connection state machine transition (`STATE CHANGED`).
219 ///
220 /// Parsed from `[UnityCrossThreadLogger]STATE CHANGED {"old":"...","new":"..."}`
221 /// entries. Payload is `{"old": "<state>", "new": "<state>"}`. Drives the
222 /// connection health indicator (AC-DET-1) — the definitive signal for
223 /// local-client disconnect detection.
224 /// Class 1 — interactive dispatch.
225 MatchConnectionState(MatchConnectionStateEvent),
226
227 /// TCP connection close event (`Client.TcpConnection.Close`).
228 ///
229 /// Parsed from `[UnityCrossThreadLogger]Client.TcpConnection.Close {...}`
230 /// entries. The payload is the full parsed JSON from the log line,
231 /// preserving `status`, `reason`, and abnormal-close-only fields
232 /// (`function`, `description`, `exception`). Feeds the desktop
233 /// connection health monitor (AC-DET-2); the parser is agnostic to
234 /// `status` semantics (per ADR-011).
235 /// Class 1 — interactive dispatch.
236 TcpConnectionClose(TcpConnectionCloseEvent),
237
238 /// WebSocket close event (`GREConnection.HandleWebSocketClosed`).
239 ///
240 /// Parsed from
241 /// `[UnityCrossThreadLogger]GREConnection.HandleWebSocketClosed {...}`
242 /// entries. The payload is the full parsed JSON from the log line,
243 /// which always includes `closeType`, `reason`, and a nested `tcpConn`
244 /// object snapshot of the paired TCP connection. Feeds the desktop
245 /// connection health monitor (AC-DET-3); the parser is agnostic to
246 /// `closeType` semantics (per ADR-011).
247 /// Class 1 — interactive dispatch.
248 WebSocketClosed(WebSocketClosedEvent),
249
250 /// Connection error event (error-path markers).
251 ///
252 /// Parsed from four JSON-bearing markers under `[UnityCrossThreadLogger]`:
253 /// `TcpConnection.ProcessRead.Exception`,
254 /// `Client.TcpConnection.ProcessFailure`,
255 /// `GREConnection.MatchDoorConnectionError`, and
256 /// `TcpConnection.Close.Exception`. Each variant is discriminated by a
257 /// stable `error_type` string and wraps the full parsed JSON under a
258 /// `payload` key. Feeds the desktop connection health monitor (AC-DET-5);
259 /// the parser is agnostic to inner error-code semantics (per ADR-011).
260 /// Class 1 — interactive dispatch.
261 ConnectionError(ConnectionErrorEvent),
262}
263
264impl GameEvent {
265 /// Returns the performance class for this event.
266 ///
267 /// - Class 1: interactive dispatch (local, ≤ 100 ms)
268 /// - Class 2: durable per-event upload
269 /// - Class 3: post-game batch upload trigger
270 pub fn performance_class(&self) -> PerformanceClass {
271 match self {
272 Self::GameState(_)
273 | Self::ClientAction(_)
274 | Self::MatchState(_)
275 | Self::LogFileRotated(_)
276 | Self::DetailedLoggingStatus(_)
277 | Self::MatchConnectionState(_)
278 | Self::TcpConnectionClose(_)
279 | Self::WebSocketClosed(_)
280 | Self::ConnectionError(_) => PerformanceClass::InteractiveDispatch,
281 Self::DraftBot(_)
282 | Self::DraftHuman(_)
283 | Self::DraftComplete(_)
284 | Self::EventLifecycle(_)
285 | Self::Session(_)
286 | Self::Rank(_)
287 | Self::Collection(_)
288 | Self::Inventory(_) => PerformanceClass::DurablePerEvent,
289 Self::GameResult(_) => PerformanceClass::PostGameBatch,
290 }
291 }
292
293 /// Returns the shared metadata common to all events.
294 pub fn metadata(&self) -> &EventMetadata {
295 delegate_to_inner!(self, metadata)
296 }
297
298 /// Returns the parsed JSON payload of the event.
299 pub fn payload(&self) -> &serde_json::Value {
300 delegate_to_inner!(self, payload)
301 }
302}
303
304// ---------------------------------------------------------------------------
305// PerformanceClass
306// ---------------------------------------------------------------------------
307
308/// Performance class determining latency target and delivery path.
309///
310/// See the [feature spec performance classes][spec] for details.
311///
312/// [spec]: https://github.com/manasight/manasight-docs/blob/main/docs/requirements/feature-specs/log-file-parser.md#performance-classes
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
314#[non_exhaustive]
315pub enum PerformanceClass {
316 /// Class 1: local-only, ≤ 100 ms latency. Also accumulated for Class 3.
317 InteractiveDispatch,
318 /// Class 2: persisted to disk queue, uploaded individually, ≤ 1 s.
319 DurablePerEvent,
320 /// Class 3: triggers assembly and upload of the complete game batch.
321 PostGameBatch,
322}
323
324impl PerformanceClass {
325 /// Returns the numeric class identifier (1, 2, or 3).
326 ///
327 /// Useful for logging, metrics, and wire-format tagging where a compact
328 /// integer representation is preferred over the enum variant name.
329 pub fn as_class_number(&self) -> u8 {
330 match self {
331 Self::InteractiveDispatch => 1,
332 Self::DurablePerEvent => 2,
333 Self::PostGameBatch => 3,
334 }
335 }
336
337 /// Returns `true` if events in this class must be persisted to durable
338 /// storage (disk queue or disk-backed buffer) before being considered
339 /// processed.
340 ///
341 /// Class 2 events are individually persisted to a disk queue for
342 /// per-event upload. Class 3 events trigger batch assembly from a
343 /// disk-backed game buffer. Class 1 events are local-only and do not
344 /// require durable storage (though they are also accumulated into the
345 /// Class 3 buffer asynchronously).
346 pub fn requires_durable_storage(&self) -> bool {
347 match self {
348 Self::InteractiveDispatch => false,
349 Self::DurablePerEvent | Self::PostGameBatch => true,
350 }
351 }
352
353 /// Returns `true` if this class triggers post-game batch assembly.
354 ///
355 /// Only Class 3 (`PostGameBatch`) triggers the assembly and upload of
356 /// the accumulated game buffer. Downstream consumers use this to know
357 /// when to finalize and ship the game record.
358 pub fn is_batch_trigger(&self) -> bool {
359 matches!(self, Self::PostGameBatch)
360 }
361}
362
363// ---------------------------------------------------------------------------
364// EventMetadata
365// ---------------------------------------------------------------------------
366
367/// Fields shared by every event: timestamp, raw bytes, and raw-bytes hash.
368///
369/// Constructed via [`EventMetadata::new`], which computes the `raw_bytes_hash`
370/// from `raw_bytes` to enforce the invariant that the hash always matches.
371/// This is critical for server-side deduplication via event fingerprints.
372///
373/// The `timestamp` is `Option<DateTime<Utc>>` because some log entries lack
374/// a parseable timestamp in the header. `None` means "no timestamp found in
375/// the log entry" — downstream consumers must handle this explicitly rather
376/// than receiving a synthetic `Utc::now()` that would break fingerprinting
377/// and chronological ordering.
378///
379/// All fields are private to protect the hash invariant. Use the accessor
380/// methods to read them.
381///
382/// Deserialization also enforces this invariant: the hash is recomputed from
383/// `raw_bytes` during deserialization rather than trusting the serialized value.
384#[derive(Debug, Clone, PartialEq, Serialize)]
385pub struct EventMetadata {
386 /// UTC timestamp parsed from the log entry header, or `None` if the
387 /// entry did not contain a parseable timestamp.
388 timestamp: Option<DateTime<Utc>>,
389
390 /// Original log entry bytes, serialized as base64. Private to prevent
391 /// mutation that would break the `raw_bytes_hash` invariant.
392 #[serde(with = "base64_serde")]
393 raw_bytes: Vec<u8>,
394
395 /// SHA-256 hash of `raw_bytes`, serialized as lowercase hex.
396 /// Precomputed at construction time. Used as part of the event
397 /// fingerprint for server-side deduplication.
398 #[serde(with = "hex_serde")]
399 raw_bytes_hash: [u8; 32],
400}
401
402impl EventMetadata {
403 /// Creates a new `EventMetadata`, computing `raw_bytes_hash` as the
404 /// SHA-256 digest of `raw_bytes`.
405 ///
406 /// `timestamp` is `None` when the log entry header did not contain a
407 /// parseable timestamp. This preserves the distinction between "real
408 /// timestamp from the log" and "no timestamp available" for downstream
409 /// consumers.
410 pub fn new(timestamp: Option<DateTime<Utc>>, raw_bytes: Vec<u8>) -> Self {
411 let raw_bytes_hash: [u8; 32] = Sha256::digest(&raw_bytes).into();
412 Self {
413 timestamp,
414 raw_bytes,
415 raw_bytes_hash,
416 }
417 }
418
419 /// Returns the UTC timestamp parsed from the log entry header, or
420 /// `None` if the entry did not contain a parseable timestamp.
421 pub fn timestamp(&self) -> Option<DateTime<Utc>> {
422 self.timestamp
423 }
424
425 /// Returns the original log entry bytes.
426 pub fn raw_bytes(&self) -> &[u8] {
427 &self.raw_bytes
428 }
429
430 /// Returns the SHA-256 hash of `raw_bytes`.
431 pub fn raw_bytes_hash(&self) -> &[u8; 32] {
432 &self.raw_bytes_hash
433 }
434}
435
436/// Custom `Deserialize` that recomputes `raw_bytes_hash` from `raw_bytes`,
437/// ensuring the hash invariant survives serialization round-trips.
438impl<'de> Deserialize<'de> for EventMetadata {
439 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
440 where
441 D: serde::Deserializer<'de>,
442 {
443 /// Wire format for deserializing `EventMetadata`. The
444 /// `raw_bytes_hash` field is optional and discarded — the real
445 /// hash is always recomputed from `raw_bytes`.
446 #[derive(Deserialize)]
447 struct EventMetadataWire {
448 timestamp: Option<DateTime<Utc>>,
449 #[serde(with = "base64_serde")]
450 raw_bytes: Vec<u8>,
451 // Accepts any format (hex string, integer array) or absence.
452 // The value is discarded — hash is always recomputed.
453 #[serde(default, rename = "raw_bytes_hash")]
454 _raw_bytes_hash: serde::de::IgnoredAny,
455 }
456
457 let wire = EventMetadataWire::deserialize(deserializer)?;
458 Ok(EventMetadata::new(wire.timestamp, wire.raw_bytes))
459 }
460}
461
462// ---------------------------------------------------------------------------
463// Class 1: Interactive Dispatch
464// ---------------------------------------------------------------------------
465
466define_event! {
467 /// GRE-to-client game state messages.
468 ///
469 /// Covers `GameStateMessage`, `ConnectResp`, and `QueuedGameStateMessage`
470 /// payloads from `greToClientEvent` entries.
471 GameStateEvent
472}
473
474define_event! {
475 /// Client-to-GRE player actions.
476 ///
477 /// Covers `SelectNResp`, `SubmitDeckResp`, `MulliganResp`, and other
478 /// `ClientToGREMessage` payloads.
479 ClientActionEvent
480}
481
482define_event! {
483 /// Match room state transitions.
484 ///
485 /// Parsed from `matchGameRoomStateChangedEvent` entries. Signals match
486 /// start/end and triggers overlay state transitions.
487 MatchStateEvent
488}
489
490// ---------------------------------------------------------------------------
491// Class 2: Durable Per-Event
492// ---------------------------------------------------------------------------
493
494define_event! {
495 /// Bot draft pick events.
496 ///
497 /// Parsed from `DraftStatus: "PickNext"` and `BotDraft_DraftPick` entries.
498 /// Each pick is independently valuable and must survive crashes.
499 DraftBotEvent
500}
501
502define_event! {
503 /// Human draft pick events.
504 ///
505 /// Parsed from `Draft.Notify`, `EventPlayerDraftMakePick`, and
506 /// `LogBusinessEvents` entries containing `PickGrpId`.
507 DraftHumanEvent
508}
509
510define_event! {
511 /// Draft completion event.
512 ///
513 /// Parsed from `Draft_CompleteDraft`. Links the draft ID to the event
514 /// and marks the draft as finished.
515 DraftCompleteEvent
516}
517
518define_event! {
519 /// Event lifecycle transitions.
520 ///
521 /// Covers `==> EventJoin`, `==> EventClaimPrize`, and
522 /// `==> EventEnterPairing`. Each is independently meaningful.
523 EventLifecycleEvent
524}
525
526define_event! {
527 /// Session identity and connection events.
528 ///
529 /// Covers `Updated account. DisplayName:`, `authenticateResponse`,
530 /// and `FrontDoorConnection.Close`. Needed to tag all subsequent events
531 /// with player identity.
532 SessionEvent
533}
534
535define_event! {
536 /// Rank snapshot.
537 ///
538 /// Parsed from `<== RankGetCombinedRankInfo`. Infrequent, small,
539 /// independently useful.
540 RankEvent
541}
542
543define_event! {
544 /// Card collection snapshot.
545 ///
546 /// Parsed from `<== StartHook` responses containing `PlayerCards`.
547 /// Enables future deck building features. Best-effort collection.
548 CollectionEvent
549}
550
551define_event! {
552 /// Inventory snapshot.
553 ///
554 /// Parsed from `<== StartHook` responses containing `InventoryInfo`.
555 /// Contains currency, wildcards, boosters, and vault progress.
556 InventoryEvent
557}
558
559// ---------------------------------------------------------------------------
560// Class 3: Post-Game Batch
561// ---------------------------------------------------------------------------
562
563define_event! {
564 /// Game result event — triggers post-game batch assembly.
565 ///
566 /// Parsed from `LogBusinessEvents` with `WinningType` and
567 /// `GameStage_GameOver`. When this event fires, the desktop app
568 /// serializes the disk-backed game buffer into a single compressed
569 /// payload and uploads it.
570 GameResultEvent
571}
572
573// ---------------------------------------------------------------------------
574// Infrastructure events
575// ---------------------------------------------------------------------------
576
577define_event! {
578 /// Log file rotation event.
579 ///
580 /// Emitted when the file tailer detects that `Player.log` was replaced
581 /// (MTGA restart). The payload contains `previous_file_size` — the byte
582 /// offset in the old file at the time rotation was detected.
583 ///
584 /// Unlike parsed log events, `raw_bytes` in the metadata is empty and
585 /// the timestamp reflects when the rotation was detected (wall-clock),
586 /// not a timestamp parsed from the log.
587 LogFileRotatedEvent
588}
589
590impl LogFileRotatedEvent {
591 /// Creates a rotation event with the given detection timestamp and the
592 /// byte offset in the old file.
593 pub fn for_rotation(timestamp: DateTime<Utc>, previous_file_size: u64) -> Self {
594 let metadata = EventMetadata::new(Some(timestamp), Vec::new());
595 let payload = serde_json::json!({ "previous_file_size": previous_file_size });
596 Self::new(metadata, payload)
597 }
598
599 /// Returns the byte offset in the old file when rotation was detected.
600 ///
601 /// Returns `None` only if the payload was manually constructed without
602 /// the `previous_file_size` field (not expected in normal usage).
603 pub fn previous_file_size(&self) -> Option<u64> {
604 self.payload()["previous_file_size"].as_u64()
605 }
606}
607
608define_event! {
609 /// Detailed logging status event.
610 ///
611 /// Emitted when the file tailer detects whether Arena's "Detailed Logs
612 /// (Plugin Support)" setting is enabled. The payload contains `enabled`
613 /// — `false` after 30 seconds of log writes without structured headers,
614 /// `true` when structured headers are subsequently detected.
615 ///
616 /// Like `LogFileRotatedEvent`, `raw_bytes` in the metadata is empty and
617 /// the timestamp reflects wall-clock detection time.
618 DetailedLoggingStatusEvent
619}
620
621impl DetailedLoggingStatusEvent {
622 /// Creates a detailed logging status event.
623 pub fn new_status(timestamp: DateTime<Utc>, enabled: bool) -> Self {
624 let metadata = EventMetadata::new(Some(timestamp), Vec::new());
625 let payload = serde_json::json!({ "enabled": enabled });
626 Self::new(metadata, payload)
627 }
628
629 /// Returns whether detailed logging is enabled.
630 ///
631 /// Returns `None` only if the payload was manually constructed without
632 /// the `enabled` field (not expected in normal usage).
633 pub fn enabled(&self) -> Option<bool> {
634 self.payload()["enabled"].as_bool()
635 }
636}
637
638define_event! {
639 /// Match connection state machine transition event.
640 ///
641 /// Parsed from `[UnityCrossThreadLogger]STATE CHANGED {...}` entries.
642 /// The payload is the JSON object `{"old": "<state>", "new": "<state>"}`
643 /// where each state is one of the values observed in the MTGA match
644 /// connection state machine (e.g., `None`, `ConnectedToMatchDoor`,
645 /// `ConnectedToMatchDoor_ConnectingToGRE`,
646 /// `ConnectedToMatchDoor_ConnectedToGRE_Waiting`, `Playing`,
647 /// `MatchCompleted`, `Disconnected`).
648 ///
649 /// Feeds the desktop connection health monitor; see feature spec
650 /// `connection-health-indicator.md` **AC-DET-1**.
651 MatchConnectionStateEvent
652}
653
654define_event! {
655 /// TCP connection close event.
656 ///
657 /// Parsed from `[UnityCrossThreadLogger]Client.TcpConnection.Close {...}`
658 /// entries. The payload is the full parsed JSON from the log line and
659 /// carries at minimum `status` and `reason`; abnormal closes also
660 /// include `function`, `description`, and a nested `exception` tree
661 /// (with `InnerException.NativeErrorCode` on Windows/macOS).
662 ///
663 /// The parser is agnostic to `status` semantics — downstream consumers
664 /// classify close types per ADR-011. Bare-marker entries (no JSON
665 /// payload) do not produce this event.
666 ///
667 /// Feeds the desktop connection health monitor; see feature spec
668 /// `connection-health-indicator.md` **AC-DET-2**.
669 TcpConnectionCloseEvent
670}
671
672define_event! {
673 /// WebSocket close event.
674 ///
675 /// Parsed from
676 /// `[UnityCrossThreadLogger]GREConnection.HandleWebSocketClosed {...}`
677 /// entries. The payload is the full parsed JSON from the log line and
678 /// always includes `closeType`, `reason`, and a nested `tcpConn`
679 /// object snapshot of the paired TCP connection (host/port/timing/ping
680 /// stats).
681 ///
682 /// The parser is agnostic to `closeType` semantics — downstream
683 /// consumers classify close types per ADR-011.
684 ///
685 /// Feeds the desktop connection health monitor; see feature spec
686 /// `connection-health-indicator.md` **AC-DET-3**.
687 WebSocketClosedEvent
688}
689
690define_event! {
691 /// Connection error event (error-path markers).
692 ///
693 /// Parsed from four JSON-bearing error markers under
694 /// `[UnityCrossThreadLogger]`:
695 ///
696 /// | Marker | `error_type` |
697 /// |--------|--------------|
698 /// | `TcpConnection.ProcessRead.Exception` | `tcp_process_read_exception` |
699 /// | `Client.TcpConnection.ProcessFailure` | `tcp_process_failure_socket_error` |
700 /// | `GREConnection.MatchDoorConnectionError` | `gre_match_door_connection_error` |
701 /// | `TcpConnection.Close.Exception` | `tcp_close_exception` |
702 ///
703 /// The payload shape is
704 /// `{"error_type": "<discriminant>", "payload": <parsed>}`, where
705 /// `<parsed>` is the full parsed JSON from the log line preserved
706 /// unchanged. Bare-marker entries (no JSON payload) do not produce this
707 /// event; the paired JSON line on a subsequent entry emits it.
708 ///
709 /// The parser is agnostic to inner error-code semantics — downstream
710 /// consumers match on `error_type` per ADR-011.
711 ///
712 /// Feeds the desktop connection health monitor; see feature spec
713 /// `connection-health-indicator.md` **AC-DET-5**.
714 ConnectionErrorEvent
715}
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720 use base64::prelude::{Engine as _, BASE64_STANDARD};
721 use chrono::{Datelike, TimeZone};
722
723 type TestResult = Result<(), Box<dyn std::error::Error>>;
724
725 /// Helper: build an `EventMetadata` with a fixed timestamp and the
726 /// given raw bytes.
727 ///
728 /// UTC datetimes are never ambiguous so `single()` always returns
729 /// `Some`. Uses `unwrap_or_default()` because `clippy::expect_used`
730 /// is denied in `Cargo.toml [lints.clippy]` — verified: this applies
731 /// crate-wide including `#[cfg(test)]` code under `--all-targets`.
732 /// The epoch fallback (1970-01-01) would visibly fail any timestamp
733 /// assertion rather than passing silently.
734 fn make_metadata(raw: &[u8]) -> EventMetadata {
735 let timestamp = Utc
736 .with_ymd_and_hms(2026, 2, 25, 12, 0, 0)
737 .single()
738 .unwrap_or_default();
739 EventMetadata::new(Some(timestamp), raw.to_vec())
740 }
741
742 /// Helper: build all `GameEvent` variants for exhaustive testing.
743 ///
744 /// Must stay in sync with `GameEvent` variants. Compile-time
745 /// exhaustiveness is enforced by `performance_class()` and
746 /// `delegate_to_inner!`; this array is the test-only counterpart.
747 fn all_variants() -> Vec<GameEvent> {
748 let meta = make_metadata(b"test");
749 let payload = serde_json::json!({});
750 vec![
751 GameEvent::GameState(GameStateEvent::new(meta.clone(), payload.clone())),
752 GameEvent::ClientAction(ClientActionEvent::new(meta.clone(), payload.clone())),
753 GameEvent::MatchState(MatchStateEvent::new(meta.clone(), payload.clone())),
754 GameEvent::DraftBot(DraftBotEvent::new(meta.clone(), payload.clone())),
755 GameEvent::DraftHuman(DraftHumanEvent::new(meta.clone(), payload.clone())),
756 GameEvent::DraftComplete(DraftCompleteEvent::new(meta.clone(), payload.clone())),
757 GameEvent::EventLifecycle(EventLifecycleEvent::new(meta.clone(), payload.clone())),
758 GameEvent::Session(SessionEvent::new(meta.clone(), payload.clone())),
759 GameEvent::Rank(RankEvent::new(meta.clone(), payload.clone())),
760 GameEvent::Collection(CollectionEvent::new(meta.clone(), payload.clone())),
761 GameEvent::Inventory(InventoryEvent::new(meta.clone(), payload.clone())),
762 GameEvent::GameResult(GameResultEvent::new(meta.clone(), payload.clone())),
763 GameEvent::LogFileRotated(LogFileRotatedEvent::new(meta.clone(), payload.clone())),
764 GameEvent::DetailedLoggingStatus(DetailedLoggingStatusEvent::new(
765 meta.clone(),
766 payload.clone(),
767 )),
768 GameEvent::MatchConnectionState(MatchConnectionStateEvent::new(
769 meta.clone(),
770 payload.clone(),
771 )),
772 GameEvent::TcpConnectionClose(TcpConnectionCloseEvent::new(
773 meta.clone(),
774 payload.clone(),
775 )),
776 GameEvent::WebSocketClosed(WebSocketClosedEvent::new(meta.clone(), payload.clone())),
777 GameEvent::ConnectionError(ConnectionErrorEvent::new(meta.clone(), payload.clone())),
778 ]
779 }
780
781 // -- EventMetadata construction --
782
783 #[test]
784 fn test_event_metadata_new_stores_raw_bytes() {
785 let raw = b"[UnityCrossThreadLogger]some log line";
786 let meta = make_metadata(raw);
787 assert_eq!(meta.raw_bytes(), raw);
788 }
789
790 #[test]
791 fn test_event_metadata_new_computes_raw_bytes_hash() {
792 let raw = b"test payload";
793 let meta = make_metadata(raw);
794 let expected: [u8; 32] = Sha256::digest(raw).into();
795 assert_eq!(*meta.raw_bytes_hash(), expected);
796 }
797
798 #[test]
799 fn test_event_metadata_new_stores_timestamp() {
800 let meta = make_metadata(b"data");
801 let ts = meta.timestamp();
802 assert!(ts.is_some());
803 let ts = ts.unwrap_or_default();
804 assert_eq!(ts.year(), 2026);
805 assert_eq!(ts.month(), 2);
806 }
807
808 #[test]
809 fn test_event_metadata_new_enforces_hash_invariant() {
810 let raw = b"important data";
811 let meta = make_metadata(raw);
812 let expected: [u8; 32] = Sha256::digest(raw).into();
813 assert_eq!(
814 *meta.raw_bytes_hash(),
815 expected,
816 "raw_bytes_hash must always be SHA-256 of raw_bytes"
817 );
818 }
819
820 // -- EventMetadata properties --
821
822 #[test]
823 fn test_different_raw_bytes_produce_different_hashes() {
824 let meta1 = make_metadata(b"payload one");
825 let meta2 = make_metadata(b"payload two");
826 assert_ne!(meta1.raw_bytes_hash(), meta2.raw_bytes_hash());
827 }
828
829 #[test]
830 fn test_identical_raw_bytes_produce_same_hash() {
831 let meta1 = make_metadata(b"same payload");
832 let meta2 = make_metadata(b"same payload");
833 assert_eq!(meta1.raw_bytes_hash(), meta2.raw_bytes_hash());
834 }
835
836 #[test]
837 fn test_empty_raw_bytes_valid() {
838 let meta = make_metadata(b"");
839 assert!(meta.raw_bytes().is_empty());
840 let expected: [u8; 32] = Sha256::digest(b"").into();
841 assert_eq!(*meta.raw_bytes_hash(), expected);
842 }
843
844 #[test]
845 fn test_event_metadata_clone_is_equal() {
846 let meta = make_metadata(b"original");
847 let cloned = meta.clone();
848 assert_eq!(meta, cloned);
849 }
850
851 #[test]
852 fn test_event_metadata_timestamp_getter() {
853 let meta = make_metadata(b"data");
854 let ts = meta.timestamp();
855 assert!(ts.is_some());
856 let ts = ts.unwrap_or_default();
857 assert_eq!(ts.year(), 2026);
858 assert_eq!(ts.month(), 2);
859 assert_eq!(ts.day(), 25);
860 }
861
862 #[test]
863 fn test_event_metadata_none_timestamp() {
864 let meta = EventMetadata::new(None, b"data".to_vec());
865 assert!(meta.timestamp().is_none());
866 }
867
868 // -- Per-category struct field access (via accessors) --
869
870 #[test]
871 fn test_game_state_event_field_access() {
872 let event = GameStateEvent::new(
873 make_metadata(b"gre payload"),
874 serde_json::json!({"type": "GameStateMessage"}),
875 );
876 assert_eq!(event.payload()["type"], "GameStateMessage");
877 assert_eq!(event.metadata().raw_bytes(), b"gre payload");
878 }
879
880 #[test]
881 fn test_client_action_event_field_access() {
882 let event = ClientActionEvent::new(
883 make_metadata(b"client action"),
884 serde_json::json!({"type": "MulliganResp"}),
885 );
886 assert_eq!(event.payload()["type"], "MulliganResp");
887 }
888
889 #[test]
890 fn test_match_state_event_field_access() {
891 let event = MatchStateEvent::new(
892 make_metadata(b"match state"),
893 serde_json::json!(
894 {"matchGameRoomStateChangedEvent": {}}
895 ),
896 );
897 assert!(event.payload()["matchGameRoomStateChangedEvent"].is_object());
898 }
899
900 #[test]
901 fn test_draft_bot_event_field_access() {
902 let event = DraftBotEvent::new(
903 make_metadata(b"bot draft"),
904 serde_json::json!({"DraftStatus": "PickNext"}),
905 );
906 assert_eq!(event.payload()["DraftStatus"], "PickNext");
907 }
908
909 #[test]
910 fn test_draft_human_event_field_access() {
911 let event = DraftHumanEvent::new(
912 make_metadata(b"human draft"),
913 serde_json::json!({"PickGrpId": 12345}),
914 );
915 assert_eq!(event.payload()["PickGrpId"], 12345);
916 }
917
918 #[test]
919 fn test_draft_complete_event_field_access() {
920 let event = DraftCompleteEvent::new(
921 make_metadata(b"draft complete"),
922 serde_json::json!({"Draft_CompleteDraft": true}),
923 );
924 assert_eq!(
925 event.payload()["Draft_CompleteDraft"],
926 serde_json::json!(true)
927 );
928 }
929
930 #[test]
931 fn test_event_lifecycle_event_field_access() {
932 let event = EventLifecycleEvent::new(
933 make_metadata(b"event lifecycle"),
934 serde_json::json!({"action": "Event_Join"}),
935 );
936 assert_eq!(event.payload()["action"], "Event_Join");
937 }
938
939 #[test]
940 fn test_session_event_field_access() {
941 let event = SessionEvent::new(
942 make_metadata(b"session data"),
943 serde_json::json!({"DisplayName": "Player"}),
944 );
945 assert_eq!(event.payload()["DisplayName"], "Player");
946 }
947
948 #[test]
949 fn test_rank_event_field_access() {
950 let event = RankEvent::new(
951 make_metadata(b"rank data"),
952 serde_json::json!(
953 {"constructedClass": "Gold", "constructedLevel": 2}
954 ),
955 );
956 assert_eq!(event.payload()["constructedClass"], "Gold");
957 }
958
959 #[test]
960 fn test_collection_event_field_access() {
961 let event = CollectionEvent::new(
962 make_metadata(b"collection"),
963 serde_json::json!({"12345": 4, "67890": 2}),
964 );
965 assert_eq!(event.payload()["12345"], 4);
966 }
967
968 #[test]
969 fn test_inventory_event_field_access() {
970 let event = InventoryEvent::new(
971 make_metadata(b"inventory"),
972 serde_json::json!(
973 {"gold": 5000, "gems": 200, "wcCommon": 10}
974 ),
975 );
976 assert_eq!(event.payload()["gold"], 5000);
977 }
978
979 #[test]
980 fn test_game_result_event_field_access() {
981 let event = GameResultEvent::new(
982 make_metadata(b"game result"),
983 serde_json::json!(
984 {"WinningType": "Win", "GameStage": "GameOver"}
985 ),
986 );
987 assert_eq!(event.payload()["WinningType"], "Win");
988 }
989
990 // -- GameEvent enum --
991
992 #[test]
993 fn test_game_event_all_variants_have_correct_performance_class() {
994 let events = all_variants();
995
996 let expected_classes = [
997 PerformanceClass::InteractiveDispatch, // GameState
998 PerformanceClass::InteractiveDispatch, // ClientAction
999 PerformanceClass::InteractiveDispatch, // MatchState
1000 PerformanceClass::DurablePerEvent, // DraftBot
1001 PerformanceClass::DurablePerEvent, // DraftHuman
1002 PerformanceClass::DurablePerEvent, // DraftComplete
1003 PerformanceClass::DurablePerEvent, // EventLifecycle
1004 PerformanceClass::DurablePerEvent, // Session
1005 PerformanceClass::DurablePerEvent, // Rank
1006 PerformanceClass::DurablePerEvent, // Collection
1007 PerformanceClass::DurablePerEvent, // Inventory
1008 PerformanceClass::PostGameBatch, // GameResult
1009 PerformanceClass::InteractiveDispatch, // LogFileRotated
1010 PerformanceClass::InteractiveDispatch, // DetailedLoggingStatus
1011 PerformanceClass::InteractiveDispatch, // MatchConnectionState
1012 PerformanceClass::InteractiveDispatch, // TcpConnectionClose
1013 PerformanceClass::InteractiveDispatch, // WebSocketClosed
1014 PerformanceClass::InteractiveDispatch, // ConnectionError
1015 ];
1016
1017 assert_eq!(
1018 events.len(),
1019 expected_classes.len(),
1020 "all_variants() and expected_classes must have the same length"
1021 );
1022 for (event, expected) in events.iter().zip(expected_classes.iter()) {
1023 assert_eq!(&event.performance_class(), expected);
1024 }
1025 }
1026
1027 #[test]
1028 fn test_game_event_metadata_accessor_all_variants() {
1029 let raw = b"test";
1030 let events = all_variants();
1031 for event in &events {
1032 assert_eq!(event.metadata().raw_bytes(), raw);
1033 }
1034 }
1035
1036 #[test]
1037 fn test_game_event_payload_accessor_all_variants() {
1038 let events = all_variants();
1039 let expected = serde_json::json!({});
1040 for event in &events {
1041 assert_eq!(*event.payload(), expected);
1042 }
1043 }
1044
1045 // -- PerformanceClass --
1046
1047 #[test]
1048 fn test_performance_class_equality() {
1049 assert_eq!(
1050 PerformanceClass::InteractiveDispatch,
1051 PerformanceClass::InteractiveDispatch
1052 );
1053 assert_ne!(
1054 PerformanceClass::InteractiveDispatch,
1055 PerformanceClass::DurablePerEvent
1056 );
1057 assert_ne!(
1058 PerformanceClass::DurablePerEvent,
1059 PerformanceClass::PostGameBatch
1060 );
1061 }
1062
1063 #[test]
1064 fn test_performance_class_as_class_number_interactive_dispatch_returns_1() {
1065 assert_eq!(PerformanceClass::InteractiveDispatch.as_class_number(), 1);
1066 }
1067
1068 #[test]
1069 fn test_performance_class_as_class_number_durable_per_event_returns_2() {
1070 assert_eq!(PerformanceClass::DurablePerEvent.as_class_number(), 2);
1071 }
1072
1073 #[test]
1074 fn test_performance_class_as_class_number_post_game_batch_returns_3() {
1075 assert_eq!(PerformanceClass::PostGameBatch.as_class_number(), 3);
1076 }
1077
1078 #[test]
1079 fn test_performance_class_requires_durable_storage_class1_false() {
1080 assert!(!PerformanceClass::InteractiveDispatch.requires_durable_storage());
1081 }
1082
1083 #[test]
1084 fn test_performance_class_requires_durable_storage_class2_true() {
1085 assert!(PerformanceClass::DurablePerEvent.requires_durable_storage());
1086 }
1087
1088 #[test]
1089 fn test_performance_class_requires_durable_storage_class3_true() {
1090 assert!(PerformanceClass::PostGameBatch.requires_durable_storage());
1091 }
1092
1093 #[test]
1094 fn test_performance_class_is_batch_trigger_class1_false() {
1095 assert!(!PerformanceClass::InteractiveDispatch.is_batch_trigger());
1096 }
1097
1098 #[test]
1099 fn test_performance_class_is_batch_trigger_class2_false() {
1100 assert!(!PerformanceClass::DurablePerEvent.is_batch_trigger());
1101 }
1102
1103 #[test]
1104 fn test_performance_class_is_batch_trigger_class3_true() {
1105 assert!(PerformanceClass::PostGameBatch.is_batch_trigger());
1106 }
1107
1108 #[test]
1109 fn test_performance_class_class_number_matches_event_mapping() {
1110 // Verify the class numbers align with the event-to-class mapping:
1111 // Class 1 events map to InteractiveDispatch (number 1)
1112 // Class 2 events map to DurablePerEvent (number 2)
1113 // Class 3 events map to PostGameBatch (number 3)
1114 let events = all_variants();
1115 let expected_numbers: Vec<u8> = vec![
1116 1, // GameState
1117 1, // ClientAction
1118 1, // MatchState
1119 2, // DraftBot
1120 2, // DraftHuman
1121 2, // DraftComplete
1122 2, // EventLifecycle
1123 2, // Session
1124 2, // Rank
1125 2, // Collection
1126 2, // Inventory
1127 3, // GameResult
1128 1, // LogFileRotated
1129 1, // DetailedLoggingStatus
1130 1, // MatchConnectionState
1131 1, // TcpConnectionClose
1132 1, // WebSocketClosed
1133 1, // ConnectionError
1134 ];
1135 assert_eq!(events.len(), expected_numbers.len());
1136 for (event, expected_num) in events.iter().zip(expected_numbers.iter()) {
1137 assert_eq!(event.performance_class().as_class_number(), *expected_num);
1138 }
1139 }
1140
1141 // -- Serialization round-trip --
1142
1143 #[test]
1144 fn test_game_event_serde_round_trip_all_variants() -> TestResult {
1145 for event in all_variants() {
1146 let serialized = serde_json::to_string(&event)?;
1147 let deserialized: GameEvent = serde_json::from_str(&serialized)?;
1148 assert_eq!(deserialized, event);
1149 }
1150 Ok(())
1151 }
1152
1153 #[test]
1154 fn test_event_metadata_serde_round_trip() -> TestResult {
1155 let meta = make_metadata(b"round trip test");
1156 let serialized = serde_json::to_string(&meta)?;
1157 let deserialized: EventMetadata = serde_json::from_str(&serialized)?;
1158
1159 assert_eq!(deserialized, meta);
1160 Ok(())
1161 }
1162
1163 #[test]
1164 fn test_event_metadata_deserialize_recomputes_hash() -> TestResult {
1165 let meta = make_metadata(b"test data");
1166 let mut serialized: serde_json::Value = serde_json::to_value(&meta)?;
1167
1168 // Tamper with the serialized raw_bytes_hash (now a hex string)
1169 serialized["raw_bytes_hash"] = serde_json::json!("00".repeat(32));
1170
1171 let deserialized: EventMetadata = serde_json::from_value(serialized)?;
1172
1173 // Hash should be recomputed from raw_bytes, not the tampered
1174 // value
1175 assert_eq!(*deserialized.raw_bytes_hash(), *meta.raw_bytes_hash());
1176 assert_eq!(deserialized.raw_bytes(), meta.raw_bytes());
1177 Ok(())
1178 }
1179
1180 #[test]
1181 fn test_performance_class_serde_round_trip() -> TestResult {
1182 for class in [
1183 PerformanceClass::InteractiveDispatch,
1184 PerformanceClass::DurablePerEvent,
1185 PerformanceClass::PostGameBatch,
1186 ] {
1187 let serialized = serde_json::to_string(&class)?;
1188 let deserialized: PerformanceClass = serde_json::from_str(&serialized)?;
1189 assert_eq!(deserialized, class);
1190 }
1191 Ok(())
1192 }
1193
1194 // -- Wire format --
1195
1196 #[test]
1197 fn test_event_metadata_serializes_raw_bytes_as_base64() -> TestResult {
1198 let meta = make_metadata(b"hello world");
1199 let serialized: serde_json::Value = serde_json::to_value(&meta)?;
1200 assert_eq!(serialized["raw_bytes"], "aGVsbG8gd29ybGQ=");
1201 Ok(())
1202 }
1203
1204 #[test]
1205 fn test_event_metadata_serializes_raw_bytes_hash_as_hex() -> TestResult {
1206 let meta = make_metadata(b"hello world");
1207 let serialized: serde_json::Value = serde_json::to_value(&meta)?;
1208 let hash_str = serialized["raw_bytes_hash"]
1209 .as_str()
1210 .ok_or("raw_bytes_hash should be a string")?;
1211 // Known SHA-256 of "hello world"
1212 assert_eq!(
1213 hash_str,
1214 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
1215 );
1216 Ok(())
1217 }
1218
1219 #[test]
1220 fn test_event_metadata_deserialize_missing_raw_bytes_hash() -> TestResult {
1221 // Forward-compatibility: raw_bytes_hash absent from wire format
1222 let json = serde_json::json!({
1223 "timestamp": "2026-02-25T12:00:00Z",
1224 "raw_bytes": BASE64_STANDARD.encode(b"test data"),
1225 });
1226 let meta: EventMetadata = serde_json::from_value(json)?;
1227 let expected: [u8; 32] = Sha256::digest(b"test data").into();
1228 assert_eq!(*meta.raw_bytes_hash(), expected);
1229 assert_eq!(meta.raw_bytes(), b"test data");
1230 Ok(())
1231 }
1232
1233 #[test]
1234 fn test_event_metadata_deserialize_integer_array_raw_bytes_hash() -> TestResult {
1235 // Backward-compatibility: raw_bytes_hash in old integer array
1236 // format
1237 let json = serde_json::json!({
1238 "timestamp": "2026-02-25T12:00:00Z",
1239 "raw_bytes": BASE64_STANDARD.encode(b"data"),
1240 "raw_bytes_hash": vec![0; 32],
1241 });
1242 let meta: EventMetadata = serde_json::from_value(json)?;
1243 // Hash is recomputed, not taken from wire
1244 let expected: [u8; 32] = Sha256::digest(b"data").into();
1245 assert_eq!(*meta.raw_bytes_hash(), expected);
1246 Ok(())
1247 }
1248
1249 #[test]
1250 fn test_event_metadata_none_timestamp_serde_round_trip() -> TestResult {
1251 let meta = EventMetadata::new(None, b"no timestamp".to_vec());
1252 let serialized = serde_json::to_string(&meta)?;
1253 let deserialized: EventMetadata = serde_json::from_str(&serialized)?;
1254 assert_eq!(deserialized, meta);
1255 assert!(deserialized.timestamp().is_none());
1256 Ok(())
1257 }
1258
1259 #[test]
1260 fn test_event_metadata_deserialize_null_timestamp() -> TestResult {
1261 let json = serde_json::json!({
1262 "timestamp": null,
1263 "raw_bytes": BASE64_STANDARD.encode(b"data"),
1264 });
1265 let meta: EventMetadata = serde_json::from_value(json)?;
1266 assert!(meta.timestamp().is_none());
1267 assert_eq!(meta.raw_bytes(), b"data");
1268 Ok(())
1269 }
1270
1271 // -- LogFileRotatedEvent --
1272
1273 #[test]
1274 fn test_log_file_rotated_for_rotation_stores_previous_file_size() {
1275 let ts = Utc
1276 .with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
1277 .single()
1278 .unwrap_or_default();
1279 let event = LogFileRotatedEvent::for_rotation(ts, 42_000);
1280 assert_eq!(event.previous_file_size(), Some(42_000));
1281 }
1282
1283 #[test]
1284 fn test_log_file_rotated_for_rotation_stores_timestamp() {
1285 let ts = Utc
1286 .with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
1287 .single()
1288 .unwrap_or_default();
1289 let event = LogFileRotatedEvent::for_rotation(ts, 1000);
1290 assert_eq!(event.metadata().timestamp(), Some(ts));
1291 }
1292
1293 #[test]
1294 fn test_log_file_rotated_has_empty_raw_bytes() {
1295 let ts = Utc
1296 .with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
1297 .single()
1298 .unwrap_or_default();
1299 let event = LogFileRotatedEvent::for_rotation(ts, 500);
1300 assert!(event.metadata().raw_bytes().is_empty());
1301 }
1302
1303 #[test]
1304 fn test_log_file_rotated_serde_round_trip() -> TestResult {
1305 let ts = Utc
1306 .with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
1307 .single()
1308 .unwrap_or_default();
1309 let event = GameEvent::LogFileRotated(LogFileRotatedEvent::for_rotation(ts, 12345));
1310 let serialized = serde_json::to_string(&event)?;
1311 let deserialized: GameEvent = serde_json::from_str(&serialized)?;
1312 assert_eq!(deserialized, event);
1313 Ok(())
1314 }
1315
1316 #[test]
1317 fn test_log_file_rotated_performance_class_is_interactive() {
1318 let ts = Utc
1319 .with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
1320 .single()
1321 .unwrap_or_default();
1322 let event = GameEvent::LogFileRotated(LogFileRotatedEvent::for_rotation(ts, 0));
1323 assert_eq!(
1324 event.performance_class(),
1325 PerformanceClass::InteractiveDispatch
1326 );
1327 }
1328
1329 // -- DetailedLoggingStatusEvent --
1330
1331 #[test]
1332 fn test_detailed_logging_status_new_status_stores_enabled_true() {
1333 let ts = Utc
1334 .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1335 .single()
1336 .unwrap_or_default();
1337 let event = DetailedLoggingStatusEvent::new_status(ts, true);
1338 assert_eq!(event.enabled(), Some(true));
1339 }
1340
1341 #[test]
1342 fn test_detailed_logging_status_new_status_stores_enabled_false() {
1343 let ts = Utc
1344 .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1345 .single()
1346 .unwrap_or_default();
1347 let event = DetailedLoggingStatusEvent::new_status(ts, false);
1348 assert_eq!(event.enabled(), Some(false));
1349 }
1350
1351 #[test]
1352 fn test_detailed_logging_status_stores_timestamp() {
1353 let ts = Utc
1354 .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1355 .single()
1356 .unwrap_or_default();
1357 let event = DetailedLoggingStatusEvent::new_status(ts, true);
1358 assert_eq!(event.metadata().timestamp(), Some(ts));
1359 }
1360
1361 #[test]
1362 fn test_detailed_logging_status_has_empty_raw_bytes() {
1363 let ts = Utc
1364 .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1365 .single()
1366 .unwrap_or_default();
1367 let event = DetailedLoggingStatusEvent::new_status(ts, false);
1368 assert!(event.metadata().raw_bytes().is_empty());
1369 }
1370
1371 #[test]
1372 fn test_detailed_logging_status_serde_round_trip() -> TestResult {
1373 let ts = Utc
1374 .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1375 .single()
1376 .unwrap_or_default();
1377 let event =
1378 GameEvent::DetailedLoggingStatus(DetailedLoggingStatusEvent::new_status(ts, false));
1379 let serialized = serde_json::to_string(&event)?;
1380 let deserialized: GameEvent = serde_json::from_str(&serialized)?;
1381 assert_eq!(deserialized, event);
1382 Ok(())
1383 }
1384
1385 #[test]
1386 fn test_detailed_logging_status_performance_class_is_interactive() {
1387 let ts = Utc
1388 .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1389 .single()
1390 .unwrap_or_default();
1391 let event =
1392 GameEvent::DetailedLoggingStatus(DetailedLoggingStatusEvent::new_status(ts, true));
1393 assert_eq!(
1394 event.performance_class(),
1395 PerformanceClass::InteractiveDispatch
1396 );
1397 }
1398}