import hljs from "highlight.js/lib/core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { countDelimiters, renderMarkdown, renderMarkdownTail, renderOpenFence } from "./markdown"; import { advance, createStreamingMarkdown } from "./streaming-markdown"; /** * Test-side normalizer for the equivalence contract. The checkpoint render may * legitimately differ from today's whole-text streaming render in exactly two * transient ways while streaming: * 1. Level 2: an OPEN fence renders plain-escaped (no hljs spans) — unwrapping * hljs spans on both sides makes them comparable ("just colour" is the * accepted trade; the closed render is byte-identical). * 2. Newlines BETWEEN top-level blocks (each rendered unit emits its own * trailing newline) and marked's trailing newline inside `` before an * appended closing fence — inter-tag whitespace collapse handles both. * Text-node content inside `
` is untouched (`>…<` never matches
 * escaped code content), so code bytes are still compared exactly.
 */
function normalizeHtml(html: string): string {
  const host = document.createElement("div");
  host.innerHTML = html;
  for (const span of [...host.querySelectorAll("span")]) {
    if ([...span.classList].some((c) => c.startsWith("hljs"))) {
      span.replaceWith(...span.childNodes);
    }
  }
  return host.innerHTML
    .replace(/>\s+<")
    .replace(/\n+(?=<\/code>)/g, "")
    .trim();
}

describe("streaming-markdown", () => {
  beforeEach(() => {
    vi.restoreAllMocks();
  });

  it("degenerate case (nothing settled) is exactly today's streaming render", () => {
    const state = createStreamingMarkdown();
    const text = "Some **bold** and `code` text";
    const { prefixHtml, tailHtml } = advance(state, text);
    expect(prefixHtml).toBe("");
    expect(tailHtml).toBe(renderMarkdown(text, { streaming: true }));
  });

  it("settles a paragraph at a blank-line boundary and matches the full render", () => {
    const state = createStreamingMarkdown();
    // The settle commits only once the boundary's lookahead line completes.
    advance(state, "first para\n\nnext");
    expect(state.prefixHtml).toBe("");
    advance(state, "first para\n\nnext\n");
    // The settle point is the START of the blank run (the separator blanks
    // lead the tail, where marked ignores them — the rendered HTML is the
    // same either way).
    expect(state.prefixHtml).toBe(renderMarkdown("first para\n"));
    expect(state.settledOffset).toBe("first para\n".length);
    // Equivalence against the whole-text render.
    const { prefixHtml, tailHtml } = advance(state, "first para\n\nnext\n");
    expect(normalizeHtml(prefixHtml + tailHtml)).toBe(
      normalizeHtml(renderMarkdown("first para\n\nnext\n", { streaming: true })),
    );
  });

  it("matches the full render while inside an open list (no checkpoints — degraded caching, never wrong)", () => {
    const state = createStreamingMarkdown();
    const text = "- item a\n\n- item b\n\n- item c";
    let shown = "";
    for (let i = 1; i <= text.length; i++) {
      const prefix = text.slice(0, i);
      const r = advance(state, prefix);
      shown = r.prefixHtml + r.tailHtml;
      expect(normalizeHtml(shown)).toBe(normalizeHtml(renderMarkdown(prefix, { streaming: true })));
    }
  });

  it("highlight-exactly-once: zero hljs while a fence is open, one call when it closes", () => {
    const spy = vi.spyOn(hljs, "highlight");
    const state = createStreamingMarkdown();

    advance(state, "intro\n\n```js\nc");
    expect(spy).not.toHaveBeenCalled(); // open fence → plain escaped, no hljs

    advance(state, "intro\n\n```js\nconst x = 1;\n");
    expect(spy).not.toHaveBeenCalled(); // still open

    const closed = advance(state, "intro\n\n```js\nconst x = 1;\n```\n");
    expect(spy).toHaveBeenCalledTimes(1); // highlighted EXACTLY once, on close
    expect(closed.prefixHtml).toContain("hljs-keyword"); // the settled fence is colored
    // The open-fence tail is gone (nothing but prose after the fence).
    expect(closed.tailHtml).toBe(renderMarkdownTail("", state.seed));

    // Subsequent deltas do NOT re-highlight the settled block.
    advance(state, "intro\n\n```js\nconst x = 1;\n```\nafter\n");
    advance(state, "intro\n\n```js\nconst x = 1;\n```\nafter\nmore\n");
    expect(spy).toHaveBeenCalledTimes(1);
  });

  it("open fence renders plain-escaped 
 with the same classes/wrapper as the closed pipeline", () => {
    const state = createStreamingMarkdown();
    const { prefixHtml, tailHtml } = advance(state, "intro\n\n```js\nconst x = 1;");
    expect(prefixHtml).toContain("intro");
    expect(tailHtml).toContain('
');
    expect(tailHtml).toContain("data-copy"); // same copy-button wrapper
    expect(tailHtml).not.toContain("hljs-keyword"); // uncolored while open
    expect(tailHtml).toContain("const x = 1;");
  });

  it("resets the cache when the text shrinks or is replaced (not an append)", () => {
    const state = createStreamingMarkdown();
    advance(state, "para one\n\npara two\n");
    expect(state.prefixHtml).not.toBe("");
    const shrunk = advance(state, "para one\n\npara");
    expect(state.settledOffset).toBe(0);
    expect(state.prefixHtml).toBe("");
    expect(shrunk.tailHtml).toBe(renderMarkdown("para one\n\npara", { streaming: true }));
  });

  it("cache stability: prefixHtml string identity is stable across appends that settle nothing", () => {
    const state = createStreamingMarkdown();
    advance(state, "para one\n\npara two\n");
    const before = state.prefixHtml;
    advance(state, "para one\n\npara two\ntail");
    advance(state, "para one\n\npara two\ntail more");
    expect(state.prefixHtml).toBe(before); // same STRING identity, not just equal
  });

  it("link-reference-definition invalidates the cache (a later def can rewrite earlier shortrefs)", () => {
    const state = createStreamingMarkdown();
    advance(state, "see [label]\n\npara\n");
    expect(state.prefixHtml).not.toBe("");
    advance(state, "see [label]\n\npara\n\n[label]: https://example.com\n");
    // The definition line was scanned: the cache dropped, everything re-renders
    // through the tail (whole-text parse resolves the shortref).
    expect(state.settledOffset).toBe(0);
    expect(state.prefixHtml).toBe("");
    const r = advance(state, "see [label]\n\npara\n\n[label]: https://example.com\n\nafter\n");
    expect(normalizeHtml(r.prefixHtml + r.tailHtml)).toBe(
      normalizeHtml(
        renderMarkdown("see [label]\n\npara\n\n[label]: https://example.com\n\nafter\n", {
          streaming: true,
        }),
      ),
    );
  });

  it("does not checkpoint inside an unclosed HTML comment or 
 block", () => {
    const state = createStreamingMarkdown();
    const text = "