// tests/unit/renderer/reader/reader-budget.test.tsx // // 守 perf r259「大文件阅读不许把渲染进程主线程堵死」这条契约 —— // 阅读器的重管线(语法高亮 / markdown 渲染 + HTML 净化)是**同步**的, // 跑在 renderer 主线程上。实测(2026-10-21): // // 代码文件 12.3MB → hljs.highlight 3191ms + 净化 592ms(净化后 HTML 57.8MB) // markdown 5.6MB → md.render 889ms + 净化 1943ms // // main 侧阅读器上限 READER_MAX_SIZE_BYTES = 10MB ⇒ 这些量级用户真能碰到 // ⇒ 窗口「未响应」数秒。修法 = **降级**而不是截断:超预算的部分不再走 // 重管线,但**内容一字不丢**(以纯文本呈现)。 // // ── 本测锁五件事 ─────────────────────────────────────────────────── // A. splitByBudget 的三条不变量: // ① head + tail === 原文(**内容零丢失**); // ② 未超预算 ⇒ tail 为空、truncated 为 false; // ③ 超预算 ⇒ 切点落在**行边界**(不把一行劈成两半)。 // B. CodeReader:超大文件 ⇒ 只有预算内的一段被高亮(高亮 span 数有界), // 但**尾部内容仍在 DOM 里**(用户看得见完整文件),且出现降级提示条。 // C. MarkdownReader:超大文件 ⇒ 预算外的部分以
 纯文本呈现
//      (**不被 markdown 排版**),内容完整,并出现降级提示条。
//   D. 小文件**不受影响**:照常高亮 / 照常排版,且**没有**提示条。
//   E. 提示条上的「在系统应用打开」出口存在(想要完整高亮的用户有去处)。
//
// 变异思路(哪几处改动会红):
//   - splitByBudget 改成「截断丢弃尾巴」⇒ A① / B / C 红(内容丢);
//   - 预算调回 Infinity(不做降级)⇒ B / C 红(高亮 span 数无界 / 无提示条);
//   - 尾巴忘了接上(head+tail 少一段)⇒ B / C 红;
//   - 提示条不渲染 ⇒ B / C 红。
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';

vi.mock('react-i18next', () => ({
  useTranslation: () => ({ t: (k: string) => k }),
}));

import { CodeReader } from '../../../../src/renderer/src/components/reader/CodeReader';
import { MarkdownReader } from '../../../../src/renderer/src/components/reader/MarkdownReader';
import {
  READER_HIGHLIGHT_BUDGET_CHARS,
  READER_MARKDOWN_BUDGET_CHARS,
  splitByBudget,
} from '../../../../src/renderer/src/lib/reader-budget';

afterEach(() => {
  cleanup();
});

/** 造一份「行数足够多、尾部有唯一标记」的源码。 */
function makeSource(totalChars: number, marker: string): string {
  const line = 'def f(x):\n    return x + 1  # comment\n';
  let s = '';
  while (s.length < totalChars) s += line;
  s += marker;
  return s;
}

describe('splitByBudget:内容零丢失的不变量', () => {
  it('A1 未超预算 ⇒ head 就是全文,tail 为空,truncated 为 false', () => {
    const text = 'abc\ndef\n';
    const r = splitByBudget(text, 1024);
    expect(r.truncated).toBe(false);
    expect(r.tail).toBe('');
    expect(r.head).toBe(text);
    expect(r.headChars).toBe(text.length);
  });

  it('A2 超预算 ⇒ head + tail 拼回原文(一个字符都不丢)', () => {
    const text = makeSource(5000, 'TAIL-MARKER\n');
    const r = splitByBudget(text, 1000);
    expect(r.truncated).toBe(true);
    expect(r.head.length).toBeLessThanOrEqual(1000);
    expect(r.head + r.tail).toBe(text);
    expect(r.tail).toContain('TAIL-MARKER');
  });

  it('A3 超预算 ⇒ 切点落在行边界(不把一行劈成两半)', () => {
    const text = makeSource(5000, 'TAIL-MARKER\n');
    const r = splitByBudget(text, 1000);
    expect(r.head.endsWith('\n')).toBe(true);
  });

  it('A4 整段没有换行的超长单行 ⇒ 硬切在预算处,内容仍零丢失', () => {
    const text = 'x'.repeat(5000);
    const r = splitByBudget(text, 1000);
    expect(r.truncated).toBe(true);
    expect(r.head.length).toBe(1000);
    expect(r.head + r.tail).toBe(text);
  });
});

describe('CodeReader:大文件降级(内容不丢,高亮有界)', () => {
  it('B1 超大文件 ⇒ 只有预算内的一段被高亮,尾部内容仍在 DOM 里', async () => {
    // 标记用「一定会被 hljs 包成 span」的形态(def = python 关键字):
    // 若整份文件都被高亮,这个标记的文本节点就会挂在 span 下面;
    // 降级后它是  的**直接文本子节点** ⇒ 断言文本节点的父元素。
    const marker = 'def zzz_tail_marker():\n    return 1\n';
    const source = makeSource(READER_HIGHLIGHT_BUDGET_CHARS + 20000, marker);
    const { container } = render();

    // 等 hljs chunk 到位(动态 import)—— 高亮 span 出现即算到位。
    await waitFor(() => {
      expect(container.querySelectorAll('code span').length).toBeGreaterThan(0);
    });

    // ① 内容零丢失:尾部标记在 DOM 里(纯文本呈现)
    expect(container.textContent).toContain('zzz_tail_marker');
    // ② 尾部**没有被高亮**:标记所在的文本节点直接挂在  下
    const code = container.querySelector('code')!;
    const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
    let node = walker.nextNode();
    let parentTag = '';
    while (node) {
      if (node.textContent?.includes('zzz_tail_marker')) {
        parentTag = (node.parentElement?.tagName ?? '').toUpperCase();
        break;
      }
      node = walker.nextNode();
    }
    expect(
      parentTag,
      '尾部被高亮了 ⇒ 渲染预算没生效(整份文件仍在跑 hljs,主线程会卡数秒)',
    ).toBe('CODE');
  });

  it('B2 出现降级提示条(用户有权知道「后面那段没高亮」)', async () => {
    const source = makeSource(READER_HIGHLIGHT_BUDGET_CHARS + 20000, 'TAIL\n');
    render();

    await waitFor(() => expect(screen.getByRole('status')).toBeTruthy());
    expect(screen.getByRole('status').textContent).toContain('reader.largeCodeNotice');
  });

  it('B3 提示条带「在系统应用打开」出口(想要完整高亮有去处)', async () => {
    const source = makeSource(READER_HIGHLIGHT_BUDGET_CHARS + 20000, 'TAIL\n');
    const onOpenExternal = vi.fn();
    render();

    const btn = await screen.findByRole('button', { name: 'reader.openExternal' });
    btn.click();
    expect(onOpenExternal).toHaveBeenCalledTimes(1);
  });

  it('D1 小文件 ⇒ 照常高亮,且**没有**降级提示条', async () => {
    const source = 'def f(x):\n    return x + 1\n';
    const { container } = render();

    await waitFor(() => {
      expect(container.querySelectorAll('code span').length).toBeGreaterThan(0);
    });
    expect(screen.queryByRole('status')).toBeNull();
  });
});

describe('MarkdownReader:大文件降级(排版有界,内容不丢)', () => {
  it('C1 超大文件 ⇒ 预算外的部分以纯文本呈现,不被 markdown 排版', async () => {
    const marker = 'TAIL-MARKER-MD **not-bold**';
    const head = '# Title\n\nsome **bold** text\n\n';
    let source = head;
    while (source.length < READER_MARKDOWN_BUDGET_CHARS + 20000) source += 'line of text\n\n';
    source += marker + '\n';

    const { container } = render();

    await waitFor(() => {
      expect(container.querySelector('article > div')!.innerHTML.trim().length).toBeGreaterThan(0);
    });

    // ① 内容零丢失:尾部标记在 DOM 里(以 
 纯文本呈现)
    expect(container.textContent).toContain('TAIL-MARKER-MD');
    // ② 预算外的部分**没有**进 markdown 渲染产物 —— 这是本测的关键断言:
    //    markdown 输出容器里不得出现尾部标记(否则说明整份文件仍在走
    //    md.render,主线程照样被堵数秒)。
    const mdOut = container.querySelector('article > div')!;
    expect(
      mdOut.textContent,
      '尾部被 markdown 渲染了 ⇒ 渲染预算没生效(整份文件仍在跑 md.render)',
    ).not.toContain('TAIL-MARKER-MD');
    // ③ 预算内的部分照常排版(只有一个 strong,来自正文)
    expect(mdOut.querySelectorAll('strong')).toHaveLength(1);
    expect(container.querySelector('article strong')!.textContent).toBe('bold');
    expect(container.textContent).toContain('**not-bold**');
  });

  it('C2 出现降级提示条', async () => {
    let source = '# Title\n\n';
    while (source.length < READER_MARKDOWN_BUDGET_CHARS + 20000) source += 'line of text\n\n';
    render();

    await waitFor(() => expect(screen.getByRole('status')).toBeTruthy());
    expect(screen.getByRole('status').textContent).toContain('reader.largeMarkdownNotice');
  });

  it('D2 小文件 ⇒ 照常排版,且没有降级提示条', async () => {
    const { container } = render(
      ,
    );
    await waitFor(() => {
      expect(container.querySelector('article strong')!.textContent).toBe('粗体');
    });
    expect(screen.queryByRole('status')).toBeNull();
  });
});