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