/**
 * Tests for components/logpanel.js
 *
 * Verifies fixes for the "blank log panel on first load" bug:
 *   1. When the logs API returns [], loadLogsForResearch must not
 *      overwrite live entries that arrived via socket events during
 *      the fetch.
 *   2. dataset.loaded must NOT be set after an empty API response, so
 *      a future toggle (or pre-fetch) re-fetches.
 *   3. dataset.loaded IS set after a successful non-empty fetch, so
 *      subsequent toggles don't re-fetch.
 *   4. When the API returns entries while live socket entries already
 *      exist, the fetched batch is merged via addLogEntryToPanel
 *      (which dedupes) instead of clobbering with innerHTML.
 */

let logPanel;

beforeAll(async () => {
    // logpanel.js destructures window.LdrLogHelpers at IIFE-time.
    await import('@js/utils/log-helpers.js');

    // Stubs the IIFE expects to find on window.
    window.escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, '');
    window.URLBuilder = {
        researchLogs: (id) => `/api/research/${id}/logs`,
        historyLogCount: (id) => `/api/research/${id}/log_count`,
    };

    // Pretend we're on a research page so the auto-initialize path runs.
    // Spread doesn't copy non-enumerable props off the Location prototype, so
    // explicitly include `search` and `hash` — initializeLogPanel reads them
    // for its debug-flag check (logpanel.js:321).
    Object.defineProperty(window, 'location', {
        configurable: true,
        value: { ...window.location, pathname: '/', search: '', hash: '' },
    });

    await import('@js/components/logpanel.js');
    logPanel = window.logPanel;
});

beforeEach(() => {
    // Build the minimal DOM the panel queries by id.
    document.body.innerHTML = `
        <div class="ldr-collapsible-log-panel">
            <div id="log-panel-toggle">
                <i class="ldr-toggle-icon"></i>
            </div>
            <div id="log-panel-content">
                <div id="console-log-container"></div>
            </div>
        </div>
        <template id="console-log-entry-template">
            <div class="ldr-console-log-entry">
                <span class="ldr-log-timestamp"></span>
                <span class="ldr-log-badge"></span>
                <span class="ldr-log-message"></span>
            </div>
        </template>
    `;

    // Reset shared state between tests.
    if (window._logPanelState) {
        window._logPanelState.queuedLogs = [];
        window._logPanelState.expanded = false;
        window._logPanelState.logCount = 0;
        window._logPanelState.currentFilter = 'all';
        window._logPanelState.autoscroll = true;
        // Force re-binding of click handlers in tests that call initialize();
        // tests that only exercise loadLogs/addLog don't rely on this.
        window._logPanelState.initialized = false;
        window._logPanelState.connectedResearchId = null;
    }
});

/**
 * Build the full log-panel DOM (filter buttons, autoscroll button, etc.)
 * inside an optional research-page wrapper, then call logPanel.initialize so
 * the click handlers from initializeLogPanel get bound.
 *
 * @param {Object} opts
 * @param {'progress'|'results'|null} [opts.page] - Wrap the panel in a
 *   research page container so initializeLogPanel sees a research page.
 *   'progress' makes the toggle handler take the new CSS-flex branch from
 *   PR #3851; 'results' takes the legacy autoscroll-hide branch.
 * @param {string} [opts.researchId] - Passed through to initialize();
 *   each call uses a fresh ID to bypass the same-ID early return.
 */
function setupPanelDom({ page = 'progress', researchId } = {}) {
    // Reset the document body, then optionally wrap the panel in a research
    // page container so initializeLogPanel sees a research page.
    document.body.innerHTML = `
        <div class="ldr-collapsible-log-panel">
            <div class="ldr-log-panel-header" id="log-panel-toggle">
                <i class="fas fa-chevron-right ldr-toggle-icon"></i>
            </div>
            <div class="ldr-log-panel-content collapsed" id="log-panel-content">
                <div class="ldr-log-controls">
                    <div class="ldr-log-filter">
                        <div class="ldr-filter-buttons">
                            <button class="ldr-small-btn ldr-selected">All</button>
                            <button class="ldr-small-btn">Milestones</button>
                            <button class="ldr-small-btn">Info</button>
                            <button class="ldr-small-btn">Warning</button>
                            <button class="ldr-small-btn">Errors</button>
                        </div>
                    </div>
                    <button id="log-autoscroll-button" class="ldr-selected"></button>
                </div>
                <div class="ldr-console-log" id="console-log-container"></div>
            </div>
        </div>
        <template id="console-log-entry-template">
            <div class="ldr-console-log-entry">
                <span class="ldr-log-timestamp"></span>
                <span class="ldr-log-badge"></span>
                <span class="ldr-log-message"></span>
            </div>
        </template>
    `;

    if (page === 'progress' || page === 'results') {
        const wrapper = document.createElement('div');
        wrapper.id = page === 'progress' ? 'research-progress' : 'research-results';
        const panel = document.querySelector('.ldr-collapsible-log-panel');
        document.body.insertBefore(wrapper, panel);
        wrapper.appendChild(panel);
    }

    // Each test uses a fresh research ID so initialize() doesn't short-circuit
    // on the same-ID check at logpanel.js:44.
    const rid = researchId || `rid-${Math.random().toString(36).slice(2)}`;
    logPanel.initialize(rid);
}

function makeLiveEntry(message) {
    // Mimic what addLogEntryToPanel produces in the DOM.
    const entry = document.createElement('div');
    entry.className = 'ldr-console-log-entry';
    entry.dataset.logId = `live-${message}`;
    const span = document.createElement('span');
    span.className = 'ldr-log-message';
    span.textContent = message;
    entry.appendChild(span);
    return entry;
}

describe('loadLogsForResearch — empty API response', () => {
    it('does not clobber live socket-driven entries when API returns []', async () => {
        const container = document.getElementById('console-log-container');
        container.appendChild(makeLiveEntry('socket-arrived-A'));
        container.appendChild(makeLiveEntry('socket-arrived-B'));

        // Simulate empty API response.
        globalThis.fetch = vi.fn(() =>
            Promise.resolve({ json: () => Promise.resolve([]) })
        );

        await logPanel.loadLogs('test-research-1');

        // Live entries must still be in the DOM.
        const entries = container.querySelectorAll('.ldr-console-log-entry');
        expect(entries.length).toBe(2);
        // The empty-state placeholder must NOT have replaced them.
        expect(container.querySelector('.ldr-empty-log-message')).toBeNull();
    });

    it('writes the empty placeholder when the container has no live entries', async () => {
        globalThis.fetch = vi.fn(() =>
            Promise.resolve({ json: () => Promise.resolve([]) })
        );

        await logPanel.loadLogs('test-research-2');

        const container = document.getElementById('console-log-container');
        expect(container.querySelector('.ldr-empty-log-message')).not.toBeNull();
    });

    it('does not set dataset.loaded after an empty response', async () => {
        const panelContent = document.getElementById('log-panel-content');
        // Pretend a previous successful load set this.
        delete panelContent.dataset.loaded;

        globalThis.fetch = vi.fn(() =>
            Promise.resolve({ json: () => Promise.resolve([]) })
        );

        await logPanel.loadLogs('test-research-3');

        // Empty response must leave dataset.loaded unset so a retry can happen.
        expect(panelContent.dataset.loaded).toBeUndefined();
    });
});

describe('loadLogsForResearch — non-empty API response', () => {
    it('sets dataset.loaded after a successful non-empty fetch', async () => {
        const panelContent = document.getElementById('log-panel-content');

        globalThis.fetch = vi.fn(() =>
            Promise.resolve({
                json: () =>
                    Promise.resolve([
                        { timestamp: new Date().toISOString(), message: 'hello', log_type: 'info' },
                    ]),
            })
        );

        await logPanel.loadLogs('test-research-4');

        expect(panelContent.dataset.loaded).toBe('true');
    });

    it('merges via addLogEntryToPanel when live entries already exist', async () => {
        const container = document.getElementById('console-log-container');
        container.appendChild(makeLiveEntry('live-only'));

        globalThis.fetch = vi.fn(() =>
            Promise.resolve({
                json: () =>
                    Promise.resolve([
                        { timestamp: new Date().toISOString(), message: 'fetched', log_type: 'info' },
                    ]),
            })
        );

        await logPanel.loadLogs('test-research-5');

        // The live entry must survive (not overwritten by innerHTML reset).
        const messages = Array.from(
            container.querySelectorAll('.ldr-log-message')
        ).map((el) => el.textContent);
        expect(messages).toContain('live-only');
    });
});

describe('loadLogsForResearch — in-flight deduplication', () => {
    it('skips a duplicate fetch while one is already in flight', async () => {
        // Hold the first fetch open until we explicitly resolve it, so the
        // second call lands while the first is still pending.
        let resolveFirst;
        const firstResponse = new Promise((resolve) => {
            resolveFirst = resolve;
        });
        const fetchSpy = vi.fn(() => firstResponse);
        globalThis.fetch = fetchSpy;

        const firstCall = logPanel.loadLogs('test-research-dedup');
        // While first is in flight, kick off a second call — it must be a no-op.
        const secondCall = logPanel.loadLogs('test-research-dedup');
        await secondCall;

        // Only one fetch should have happened so far.
        expect(fetchSpy).toHaveBeenCalledTimes(1);

        // Resolve the first call so it can finish cleanly.
        resolveFirst({ json: () => Promise.resolve([]) });
        await firstCall;
    });

    it('clears the in-flight flag after completion so future calls can run', async () => {
        globalThis.fetch = vi.fn(() =>
            Promise.resolve({ json: () => Promise.resolve([]) })
        );

        await logPanel.loadLogs('test-research-cleared-1');
        // Second call after the first completes must execute (not be deduped).
        await logPanel.loadLogs('test-research-cleared-2');

        expect(globalThis.fetch).toHaveBeenCalledTimes(2);
    });

    it('clears dataset.loading even when fetch rejects', async () => {
        // If a refactor drops the `finally` block that clears
        // dataset.loading, a single network error would permanently lock
        // the panel into "skipping duplicate" mode for the rest of the
        // page lifetime — exactly the silent-blank-panel class of bug
        // this PR is fixing.
        const panelContent = document.getElementById('log-panel-content');
        globalThis.fetch = vi.fn(() => Promise.reject(new Error('net down')));

        await logPanel.loadLogs('test-research-throws');

        expect(panelContent.dataset.loading).toBeUndefined();

        // A follow-up call must actually fire fetch again, not be deduped.
        globalThis.fetch = vi.fn(() =>
            Promise.resolve({ json: () => Promise.resolve([]) })
        );
        await logPanel.loadLogs('test-research-throws');
        expect(globalThis.fetch).toHaveBeenCalledTimes(1);
    });
});

describe('addConsoleLog — placeholder removal', () => {
    it('removes the empty-state placeholder when adding a live entry', () => {
        const container = document.getElementById('console-log-container');
        container.innerHTML =
            '<div class="ldr-empty-log-message">No logs available.</div>';

        // Force the panel into an expanded state so addConsoleLog goes
        // straight to addLogEntryToPanel rather than queuing.
        window._logPanelState.expanded = true;

        logPanel.addLog('first live log', 'info');

        // Placeholder is gone, real entry took its place.
        expect(container.querySelector('.ldr-empty-log-message')).toBeNull();
        expect(container.querySelector('.ldr-console-log-entry')).not.toBeNull();
    });
});

// Ordering invariants for the #2610 fix (PR #3850). The log panel uses
// `flex-direction: column-reverse` so DOM end == visual top. happy-dom
// does not render CSS, so these tests assert on DOM order directly --
// the contract being locked in is "DOM order is chronological
// oldest -> newest", and the CSS flip is taken as given.
//
// Mirrors of source-side constants (kept inline because both are `const`
// inside the IIFE in logpanel.js and not exported). If either source
// constant changes, update here:
//   MAX_LOG_ENTRIES   src/local_deep_research/web/static/js/components/logpanel.js:21
//   DEDUP_WINDOW      src/local_deep_research/web/static/js/components/logpanel.js
//                     (the `existingEntries.length - 10` lower bound in
//                      addLogEntryToPanel's dedup-by-content scan)
const MAX_LOG_ENTRIES = 500;
const DEDUP_WINDOW = 10;

describe('addLog / loadLogs — ordering invariants', () => {
    function messageTextsInDomOrder(container) {
        return Array.from(container.querySelectorAll('.ldr-log-message')).map(
            (el) => el.textContent
        );
    }

    beforeEach(() => {
        // Drive entries through addLogEntryToPanel rather than the queue.
        window._logPanelState.expanded = true;
        // Fake all timers — addLogEntryToPanel queues a setTimeout(autoscroll, 0)
        // per call. Under parallel vitest load the prune test (501 calls)
        // would pile up 501 real-timer tasks and time out at 5s. The tests
        // here assert DOM contents, not scroll behavior, so leaving the
        // autoscroll setTimeouts queued (unflushed) is intentional.
        vi.useFakeTimers();
    });

    afterEach(() => {
        // Drop any queued autoscroll setTimeouts before switching back to
        // real timers, so they don't leak into a subsequent test.
        vi.clearAllTimers();
        vi.useRealTimers();
        // Vitest isolates globals between files, but ordering changes
        // within this file should not expose latent reliance on a prior
        // test's fetch mock.
        delete globalThis.fetch;
    });

    it('inserts live entries in chronological DOM order (newest at DOM end)', () => {
        const container = document.getElementById('console-log-container');

        vi.setSystemTime(new Date('2026-05-08T12:00:00Z'));
        logPanel.addLog('first', 'info');
        vi.setSystemTime(new Date('2026-05-08T12:00:01Z'));
        logPanel.addLog('second', 'info');
        vi.setSystemTime(new Date('2026-05-08T12:00:02Z'));
        logPanel.addLog('third', 'info');

        expect(messageTextsInDomOrder(container)).toEqual([
            'first',
            'second',
            'third',
        ]);

        // data-log-time-ms must be monotonically non-decreasing oldest -> newest.
        const times = Array.from(
            container.querySelectorAll('.ldr-console-log-entry')
        ).map((el) => Number(el.dataset.logTimeMs));
        expect(times).toEqual([...times].sort((a, b) => a - b));
    });

    it('merges late-arriving older history into chronological position', async () => {
        const container = document.getElementById('console-log-container');

        // Two live entries arrive first (recent times).
        vi.setSystemTime(new Date('2026-05-08T12:00:00Z'));
        logPanel.addLog('live-A', 'info');
        vi.setSystemTime(new Date('2026-05-08T12:00:01Z'));
        logPanel.addLog('live-B', 'info');

        // Then loadLogs returns one historical entry whose timestamp is
        // older than both live entries. The merge path routes through
        // addLogEntryToPanel, which must insert it before live-A.
        globalThis.fetch = vi.fn(() =>
            Promise.resolve({
                json: () =>
                    Promise.resolve([
                        {
                            timestamp: '2026-05-08T11:59:00Z',
                            message: 'historical',
                            log_type: 'info',
                        },
                    ]),
            })
        );

        await logPanel.loadLogs('test-research-ordering-merge');

        expect(messageTextsInDomOrder(container)).toEqual([
            'historical',
            'live-A',
            'live-B',
        ]);
    });

    // Explicit timeout: this test is genuinely heavy, not hung. 501 inserts
    // each run addLogEntryToPanel's full-container querySelectorAll scans
    // (dedup window, chronological insert position, prune check) — O(n²)
    // DOM work that takes ~2.6-2.9s in happy-dom on a dev machine, so the
    // 5s vitest default leaves no headroom under parallel CI load. #4304
    // already fixed the *timer* pileup (501 real setTimeout(autoscroll, 0)
    // tasks) by faking all timers; what remains is honest compute, so a
    // bigger budget is the right lever now — unlike #4299, which proposed
    // it while the timer bug was still the root cause. The test must fill
    // to the real MAX_LOG_ENTRIES cap to exercise the prune, so the work
    // can't be reduced without weakening the assertion.
    it('prunes the oldest entries when count exceeds MAX_LOG_ENTRIES', { timeout: 20000 }, () => {
        const container = document.getElementById('console-log-container');

        // One insert over the cap. The live-insert prune in
        // addLogEntryToPanel must drop the oldest entry, not the newest.
        const totalInserts = MAX_LOG_ENTRIES + 1;
        const base = new Date('2026-05-08T12:00:00Z').getTime();
        for (let i = 0; i < totalInserts; i++) {
            vi.setSystemTime(new Date(base + i * 1000));
            logPanel.addLog(`msg-${i}`, 'info');
        }

        const entries = container.querySelectorAll('.ldr-console-log-entry');
        expect(entries.length).toBe(MAX_LOG_ENTRIES);

        const messages = messageTextsInDomOrder(container);
        // Oldest (msg-0) was pruned; msg-1 is now the oldest in DOM,
        // msg-${totalInserts - 1} is the newest.
        expect(messages).not.toContain('msg-0');
        expect(messages[0]).toBe('msg-1');
        expect(messages[messages.length - 1]).toBe(`msg-${totalInserts - 1}`);
    });

    it('dedupes a duplicate inside the 10-newest window', () => {
        const container = document.getElementById('console-log-container');

        vi.setSystemTime(new Date('2026-05-08T12:00:00Z'));
        logPanel.addLog('dup-msg', 'info');
        vi.setSystemTime(new Date('2026-05-08T12:00:01Z'));
        logPanel.addLog('dup-msg', 'info');

        // Only one DOM entry, with a duplicate-counter badge.
        const entries = container.querySelectorAll('.ldr-console-log-entry');
        expect(entries.length).toBe(1);
        expect(entries[0].dataset.counter).toBe('2');
        const badge = entries[0].querySelector('.ldr-duplicate-counter');
        expect(badge).not.toBeNull();
        expect(badge.textContent).toBe('(2×)');
    });

    it('does not dedupe a duplicate that has fallen outside the 10-newest window', () => {
        const container = document.getElementById('console-log-container');

        // DEDUP_WINDOW + 1 distinct messages: msg-0 ends up at DOM index 0
        // (oldest), msg-${DEDUP_WINDOW} at the newest end. The
        // dedup-by-content scan only covers the DEDUP_WINDOW newest, so
        // msg-0 is one slot outside it.
        const distinctCount = DEDUP_WINDOW + 1;
        const base = new Date('2026-05-08T12:00:00Z').getTime();
        for (let i = 0; i < distinctCount; i++) {
            vi.setSystemTime(new Date(base + i * 1000));
            logPanel.addLog(`msg-${i}`, 'info');
        }

        // Re-add msg-0 with a fresh timestamp so dedup-by-id misses
        // (different id -> ${timestamp}-${hash}). Dedup-by-content would
        // catch it only if msg-0 were in the DEDUP_WINDOW newest.
        vi.setSystemTime(new Date(base + distinctCount * 1000));
        logPanel.addLog('msg-0', 'info');

        const entries = container.querySelectorAll('.ldr-console-log-entry');
        expect(entries.length).toBe(distinctCount + 1);

        // The two msg-0 entries sit at the chronological extremes.
        const messages = messageTextsInDomOrder(container);
        expect(messages[0]).toBe('msg-0');
        expect(messages[messages.length - 1]).toBe('msg-0');
    });
});

/**
 * Toggle handler tests — locks in the contract introduced by PR #3851.
 *
 * The fix replaced a JS height calc with a CSS flex layout scoped to
 * `#research-progress`. The toggle handler now toggles a `.ldr-expanded`
 * class and clears any inline `style.height` on the progress page; on
 * non-progress pages it falls back to `style.height = 'auto'` and hides the
 * autoscroll button. These tests guard against a future refactor silently
 * re-introducing a JS-driven height formula or dropping the autoscroll-hide
 * branch.
 *
 * Note: CSS layout (no scrollbar at viewport heights, panel fills available
 * space) cannot be validated in happy-dom and remains a manual browser check.
 */
describe('toggle handler — progress page', () => {
    it('toggles .ldr-expanded on/off across two clicks', () => {
        setupPanelDom({ page: 'progress' });
        const panel = document.querySelector('.ldr-collapsible-log-panel');
        const toggle = document.getElementById('log-panel-toggle');

        toggle.click();
        expect(panel.classList.contains('ldr-expanded')).toBe(true);

        toggle.click();
        expect(panel.classList.contains('ldr-expanded')).toBe(false);
    });

    it('clears any inline style.height when expanding', () => {
        setupPanelDom({ page: 'progress' });
        const panel = document.querySelector('.ldr-collapsible-log-panel');
        // Simulate a stale inline height left over from the old JS-calc code
        // path. Expanding on a progress page must clear it so the new CSS
        // flex layout can size the panel.
        panel.style.height = '500px';

        document.getElementById('log-panel-toggle').click();

        expect(panel.style.height).toBe('');
    });

    it('enables autoscroll on first expand', () => {
        setupPanelDom({ page: 'progress' });

        document.getElementById('log-panel-toggle').click();

        // The handler sets autoscroll=false then calls toggleAutoscroll(),
        // which flips it to true. Locking this in guards against a refactor
        // that drops the toggleAutoscroll() call.
        expect(window._logPanelState.autoscroll).toBe(true);
    });
});

describe('toggle handler — non-progress page', () => {
    it('does not add .ldr-expanded when there is no #research-progress', () => {
        setupPanelDom({ page: 'results' });
        const panel = document.querySelector('.ldr-collapsible-log-panel');

        document.getElementById('log-panel-toggle').click();

        expect(panel.classList.contains('ldr-expanded')).toBe(false);
    });

    it('sets style.height to auto on expand', () => {
        setupPanelDom({ page: 'results' });
        const panel = document.querySelector('.ldr-collapsible-log-panel');

        document.getElementById('log-panel-toggle').click();

        expect(panel.style.height).toBe('auto');
    });

    it('hides the autoscroll button on expand', () => {
        setupPanelDom({ page: 'results' });

        document.getElementById('log-panel-toggle').click();

        const autoscrollButton = document.getElementById('log-autoscroll-button');
        expect(autoscrollButton.style.display).toBe('none');
    });
});

describe('filter buttons', () => {
    it('moves .ldr-selected to the clicked button', () => {
        setupPanelDom({ page: 'progress' });
        const buttons = document.querySelectorAll(
            '.ldr-log-filter .ldr-filter-buttons button'
        );
        const allBtn = Array.from(buttons).find(
            (b) => b.textContent.toLowerCase() === 'all'
        );
        const errorsBtn = Array.from(buttons).find(
            (b) => b.textContent.toLowerCase() === 'errors'
        );
        expect(allBtn.classList.contains('ldr-selected')).toBe(true);

        errorsBtn.click();

        expect(allBtn.classList.contains('ldr-selected')).toBe(false);
        expect(errorsBtn.classList.contains('ldr-selected')).toBe(true);
    });

    it('updates _logPanelState.currentFilter to the clicked type', () => {
        setupPanelDom({ page: 'progress' });
        const buttons = document.querySelectorAll(
            '.ldr-log-filter .ldr-filter-buttons button'
        );
        const errorsBtn = Array.from(buttons).find(
            (b) => b.textContent.toLowerCase() === 'errors'
        );

        errorsBtn.click();

        expect(window._logPanelState.currentFilter).toBe('errors');
    });

    it('hides entries whose log type does not match the filter', () => {
        setupPanelDom({ page: 'progress' });
        // Seed the container with one info and one error entry so we can
        // verify the filter actually toggles display on each.
        window._logPanelState.expanded = true;
        logPanel.addLog('an info message', 'info');
        logPanel.addLog('an error message', 'error');

        const container = document.getElementById('console-log-container');
        const entries = container.querySelectorAll('.ldr-console-log-entry');
        expect(entries.length).toBe(2);

        const errorsBtn = Array.from(
            document.querySelectorAll(
                '.ldr-log-filter .ldr-filter-buttons button'
            )
        ).find((b) => b.textContent.toLowerCase() === 'errors');
        errorsBtn.click();

        const infoEntry = container.querySelector('.ldr-log-info');
        const errorEntry = container.querySelector('.ldr-log-error');
        expect(infoEntry.style.display).toBe('none');
        expect(errorEntry.style.display).toBe('');
    });
});

describe('queued logs', () => {
    it('queues logs added while collapsed when no toggle handler is bound', () => {
        // No initialize() call → no auto-expand handler, so the synthetic
        // toggle.click() inside addConsoleLog is a no-op and the queue
        // accumulates. This is the path that triggers when logs arrive
        // before the panel finishes initializing.
        const container = document.getElementById('console-log-container');

        logPanel.addLog('queued before init', 'info');

        expect(window._logPanelState.queuedLogs.length).toBe(1);
        expect(container.querySelector('.ldr-console-log-entry')).toBeNull();
    });

    it('drains the queue when the panel is expanded', () => {
        setupPanelDom({ page: 'progress' });
        // Pre-seed a queued entry, simulating a log that arrived while the
        // panel was still collapsed.
        window._logPanelState.queuedLogs.push({
            id: 'pre-queued-1',
            time: new Date().toISOString(),
            message: 'pre-queued',
            type: 'info',
            metadata: { type: 'info' },
        });
        expect(window._logPanelState.queuedLogs.length).toBe(1);

        document.getElementById('log-panel-toggle').click();

        expect(window._logPanelState.queuedLogs.length).toBe(0);
        const container = document.getElementById('console-log-container');
        expect(container.querySelector('.ldr-console-log-entry')).not.toBeNull();
    });

    it('bypasses the queue when the panel is already expanded', () => {
        setupPanelDom({ page: 'progress' });
        window._logPanelState.expanded = true;

        logPanel.addLog('direct', 'info');

        expect(window._logPanelState.queuedLogs.length).toBe(0);
        const container = document.getElementById('console-log-container');
        expect(container.querySelector('.ldr-console-log-entry')).not.toBeNull();
    });
});
