manasight-parser 0.6.0

MTG Arena log file parser — reads Player.log and emits typed game events
Documentation
//! Integration tests for [`manasight_parser::parse_whole_log`].
//!
//! Verifies that the sync entry point produces the same events as the
//! entry-by-entry [`Router`] path, including trailing entries not followed
//! by a header.

use manasight_parser::log::entry::LineBuffer;
use manasight_parser::router::Router;
use manasight_parser::{parse_whole_log, GameEvent};

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Feeds `input` line-by-line through a fresh `LineBuffer` + `Router`,
/// returning all collected events (the reference path).
fn parse_via_router(input: &str) -> Vec<GameEvent> {
    let mut buffer = LineBuffer::new();
    let router = Router::new();
    let mut events = Vec::new();

    for line in input.lines() {
        for entry in buffer.push_line(line) {
            events.extend(router.route(&entry));
        }
    }
    if let Some(entry) = buffer.flush() {
        events.extend(router.route(&entry));
    }

    events
}

// ---------------------------------------------------------------------------
// Parity tests: parse_whole_log vs. entry-by-entry Router path
// ---------------------------------------------------------------------------

#[test]
fn test_parse_whole_log_empty_input_returns_empty_vec() {
    let events = parse_whole_log("");
    assert!(events.is_empty());
}

#[test]
fn test_parse_whole_log_metadata_parity_with_router() {
    let input = "DETAILED LOGS: ENABLED\n";
    let via_fn = parse_whole_log(input);
    let via_router = parse_via_router(input);
    assert_eq!(
        via_fn.len(),
        via_router.len(),
        "parse_whole_log and router path must emit the same number of events"
    );
    assert_eq!(via_fn.len(), 1);
    assert!(matches!(via_fn[0], GameEvent::DetailedLoggingStatus(_)));
}

#[test]
fn test_parse_whole_log_session_event_parity_with_router() {
    let input = "[UnityCrossThreadLogger]authenticateResponse\n\
                 {\"screenName\":\"TestPlayer\"}\n\
                 [UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
                 some filler\n";

    let via_fn = parse_whole_log(input);
    let via_router = parse_via_router(input);

    assert_eq!(
        via_fn.len(),
        via_router.len(),
        "event count must match between parse_whole_log and router path"
    );
    assert_eq!(via_fn.len(), 1);
    assert!(matches!(via_fn[0], GameEvent::Session(_)));
}

#[test]
fn test_parse_whole_log_multiple_events_parity_with_router() {
    let gs_payload = serde_json::json!({
        "greToClientEvent": {
            "greToClientMessages": [{
                "type": "GREMessageType_GameStateMessage",
                "gameStateMessage": {
                    "gameInfo": { "stage": "GameStage_Play" },
                    "gameObjects": [],
                    "zones": []
                }
            }]
        }
    });

    let input = format!(
        "DETAILED LOGS: ENABLED\n\
         [UnityCrossThreadLogger]authenticateResponse\n\
         {{\"screenName\":\"TestPlayer\"}}\n\
         [UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{gs_payload}\n\
         [UnityCrossThreadLogger]2/25/2026 12:00:01 PM\nfiller\n"
    );

    let via_fn = parse_whole_log(&input);
    let via_router = parse_via_router(&input);

    assert_eq!(
        via_fn.len(),
        via_router.len(),
        "event count must match: parse_whole_log={}, router={}",
        via_fn.len(),
        via_router.len()
    );
    // Expected: DetailedLoggingStatus + Session + GameState = 3
    assert_eq!(via_fn.len(), 3);
    assert!(matches!(via_fn[0], GameEvent::DetailedLoggingStatus(_)));
    assert!(matches!(via_fn[1], GameEvent::Session(_)));
    assert!(matches!(via_fn[2], GameEvent::GameState(_)));
}

/// This test specifically exercises the trailing-entry flush path: the last
/// entry has no following header to trigger an implicit flush, so `flush()`
/// must drain it.
#[test]
fn test_parse_whole_log_trailing_entry_not_followed_by_header_parity() {
    // The session entry at the end has no subsequent header — it will only
    // be emitted if flush() is called after iterating all lines.
    let input = "[UnityCrossThreadLogger]authenticateResponse\n\
                 {\"screenName\":\"TrailingEntry\"}\n";

    let via_fn = parse_whole_log(input);
    let via_router = parse_via_router(input);

    assert_eq!(
        via_fn.len(),
        via_router.len(),
        "trailing entry must be drained by flush(): parse_whole_log={}, router={}",
        via_fn.len(),
        via_router.len()
    );
    assert_eq!(
        via_fn.len(),
        1,
        "expected exactly one event from trailing entry"
    );
    assert!(matches!(via_fn[0], GameEvent::Session(_)));
}

#[test]
fn test_parse_whole_log_unrecognized_entries_parity_with_router() {
    // Content with no parseable events — should return empty vec.
    let input = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
                 some completely unrecognized content\n\
                 [UnityCrossThreadLogger]2/25/2026 12:00:01 PM\n\
                 more unrecognized content\n";

    let via_fn = parse_whole_log(input);
    let via_router = parse_via_router(input);

    assert_eq!(
        via_fn.len(),
        via_router.len(),
        "unrecognized entries must produce identical (empty) results"
    );
    assert!(via_fn.is_empty());
}

// ---------------------------------------------------------------------------
// Frame-counter prefix (#240): UTC_Log archive variant
// ---------------------------------------------------------------------------

/// Feeds `input` line-by-line through a fresh [`LineBuffer`], returning all
/// complete [`manasight_parser::log::entry::LogEntry`] values (the layer
/// below the Router, exercising header/metadata detection directly).
fn collect_log_entries(input: &str) -> Vec<manasight_parser::log::entry::LogEntry> {
    let mut buffer = LineBuffer::new();
    let mut entries = Vec::new();
    for line in input.lines() {
        entries.extend(buffer.push_line(line));
    }
    if let Some(entry) = buffer.flush() {
        entries.push(entry);
    }
    entries
}

/// Synthesises a frame-prefixed copy of `flush_timing_corpus_slice.log` by
/// prepending `[<n>] ` to every line, then asserts the resulting `LogEntry`
/// stream is byte-identical to parsing the unprefixed original.
///
/// This reproduces the failure mode observed on newer MTGA Mac builds
/// (`UTC_Log` archive variant): before the fix, every prefixed header failed
/// to match, yielding 0 entries. After the fix the frame-counter prefix is
/// stripped in `LineBuffer::push_line` before detection, so the prefixed and
/// unprefixed logs produce the same `LogEntry` output.
///
/// The fixture is tested at the `LogEntry` level (below the Router) because
/// `flush_timing_corpus_slice.log` exercises `LineBuffer` entry-detection
/// patterns — the same layer where the frame-counter strip applies.
#[test]
fn test_frame_prefixed_fixture_log_entries_byte_identical_to_unprefixed() {
    let unprefixed = include_str!("fixtures/flush_timing_corpus_slice.log");

    // Strip comment lines as the fixture parser helper does, so we get
    // a clean comparison of real log content.
    let clean_unprefixed: String = unprefixed
        .lines()
        .filter(|line| !line.starts_with('#'))
        .fold(String::new(), |mut s, line| {
            use std::fmt::Write as _;
            let _ = writeln!(s, "{line}");
            s
        });

    // Prepend `[<n>] ` to every line with a monotonically incrementing
    // counter, mirroring the Unity frame-counter format. The exact digit
    // values must not affect parsing.
    let prefixed: String =
        clean_unprefixed
            .lines()
            .enumerate()
            .fold(String::new(), |mut s, (n, line)| {
                use std::fmt::Write as _;
                let _ = writeln!(s, "[{n}] {line}");
                s
            });

    let entries_unprefixed = collect_log_entries(&clean_unprefixed);
    let entries_prefixed = collect_log_entries(&prefixed);

    assert!(
        !entries_unprefixed.is_empty(),
        "fixture must yield at least one LogEntry — verify fixture path is correct",
    );

    assert_eq!(
        entries_prefixed.len(),
        entries_unprefixed.len(),
        "frame-prefixed log must yield the same LogEntry count as the unprefixed original \
         (got {}, expected {})",
        entries_prefixed.len(),
        entries_unprefixed.len(),
    );

    // Full entry-stream equality: headers and bodies must match.
    assert_eq!(
        entries_prefixed, entries_unprefixed,
        "frame-prefixed log must produce a byte-identical LogEntry stream to the unprefixed original",
    );
}

/// Verifies the complete `parse_whole_log` path (including Router dispatch)
/// for a frame-prefixed log that contains events the router can parse.
/// Uses `deck_submission_v2_constructed.log` which contains an `EventSetDeckV2`
/// entry that maps to a `GameEvent::DeckSubmission`.
#[test]
fn test_parse_whole_log_frame_prefixed_produces_same_game_events_as_unprefixed() {
    let unprefixed = include_str!("fixtures/deck_submission_v2_constructed.log");

    // Prepend `[<n>] ` to every line.
    let prefixed: String =
        unprefixed
            .lines()
            .enumerate()
            .fold(String::new(), |mut s, (n, line)| {
                use std::fmt::Write as _;
                let _ = writeln!(s, "[{n}] {line}");
                s
            });

    let events_unprefixed = parse_whole_log(unprefixed);
    let events_prefixed = parse_whole_log(&prefixed);

    assert!(
        !events_unprefixed.is_empty(),
        "unprefixed fixture must yield at least one GameEvent",
    );

    assert_eq!(
        events_prefixed.len(),
        events_unprefixed.len(),
        "frame-prefixed log must yield the same GameEvent count as the unprefixed original \
         (got {}, expected {})",
        events_prefixed.len(),
        events_unprefixed.len(),
    );

    assert_eq!(
        events_prefixed, events_unprefixed,
        "frame-prefixed log must produce a byte-identical GameEvent stream to the unprefixed original",
    );
}