File
Blob: tests/shared/outline-rendering.test.ts
| 1 | import { createElement } from "react"; |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | import { describe, expect, it } from "vitest"; |
| 4 | import { |
| 5 | createUniqueOutlineAnchorId, |
| 6 | normalizeOutlineText, |
| 7 | OutlinePresentation, |
| 8 | readOutlineLevel, |
| 9 | resolveViewportActiveOutlineHeading, |
| 10 | type OutlineItem, |
| 11 | } from "@/shared/editor/presentation"; |
| 12 | |
| 13 | describe("shared outline rendering", () => { |
| 14 | it("normalizes outline text, heading levels, and duplicate-safe anchor ids", () => { |
| 15 | const used = new Set<string>(); |
| 16 | |
| 17 | expect(normalizeOutlineText(" Intro\n\tsection ")).toBe("Intro section"); |
| 18 | expect(readOutlineLevel(2)).toBe(2); |
| 19 | expect(readOutlineLevel("3")).toBe(3); |
| 20 | expect(readOutlineLevel(9)).toBe(1); |
| 21 | |
| 22 | expect(createUniqueOutlineAnchorId("Intro!", used)).toBe("intro"); |
| 23 | expect(createUniqueOutlineAnchorId("Intro", used)).toBe("intro-2"); |
| 24 | expect(createUniqueOutlineAnchorId("你好", used)).toBe("section"); |
| 25 | expect(createUniqueOutlineAnchorId("?!", used)).toBe("section-2"); |
| 26 | }); |
| 27 | |
| 28 | it("renders matching button and link outline markup", () => { |
| 29 | const items: OutlineItem[] = [ |
| 30 | { id: "intro", text: "Intro", level: 1, href: "#intro" }, |
| 31 | { id: "deep", text: "Deep section", level: 3, href: "#custom-deep" }, |
| 32 | ]; |
| 33 | |
| 34 | const buttonHtml = renderToStaticMarkup( |
| 35 | createElement(OutlinePresentation, { |
| 36 | items, |
| 37 | activeId: "deep", |
| 38 | mode: "button", |
| 39 | title: "On this page", |
| 40 | }), |
| 41 | ); |
| 42 | expect(buttonHtml).toContain('class="tiptap-outline"'); |
| 43 | expect(buttonHtml).toContain('aria-label="On this page"'); |
| 44 | expect(buttonHtml).toContain('<button type="button"'); |
| 45 | expect(buttonHtml).toContain('data-outline-id="deep"'); |
| 46 | expect(buttonHtml).toContain('data-active="true"'); |
| 47 | expect(buttonHtml).toContain('aria-current="location"'); |
| 48 | expect(buttonHtml).toContain("padding-inline-start:calc(0.5rem + 1.75rem)"); |
| 49 | |
| 50 | const linkHtml = renderToStaticMarkup( |
| 51 | createElement(OutlinePresentation, { |
| 52 | items, |
| 53 | activeId: "intro", |
| 54 | mode: "link", |
| 55 | variant: "rail", |
| 56 | }), |
| 57 | ); |
| 58 | expect(linkHtml).toContain('class="tiptap-outline tiptap-outline--rail"'); |
| 59 | expect(linkHtml).toContain('<a class="tiptap-outline__button tiptap-outline__link"'); |
| 60 | expect(linkHtml).toContain('href="#custom-deep"'); |
| 61 | expect(linkHtml).toContain('data-outline-id="intro"'); |
| 62 | expect(linkHtml).toContain('data-active="true"'); |
| 63 | expect(linkHtml).toContain('aria-current="location"'); |
| 64 | }); |
| 65 | |
| 66 | it("uses the live editor viewport priority rules for active headings", () => { |
| 67 | expect( |
| 68 | resolveViewportActiveOutlineHeading( |
| 69 | [ |
| 70 | { id: "intro", top: -12, bottom: 12 }, |
| 71 | { id: "next", top: 220, bottom: 260 }, |
| 72 | ], |
| 73 | { top: 0, height: 600 }, |
| 74 | ), |
| 75 | ).toBe("intro"); |
| 76 | |
| 77 | expect( |
| 78 | resolveViewportActiveOutlineHeading( |
| 79 | [ |
| 80 | { id: "intro", top: -120, bottom: -20 }, |
| 81 | { id: "next", top: 220, bottom: 260 }, |
| 82 | ], |
| 83 | { top: 0, height: 600 }, |
| 84 | ), |
| 85 | ).toBe("next"); |
| 86 | |
| 87 | expect( |
| 88 | resolveViewportActiveOutlineHeading( |
| 89 | [ |
| 90 | { id: "intro", top: -120, bottom: -20 }, |
| 91 | { id: "next", top: 590, bottom: 640 }, |
| 92 | ], |
| 93 | { top: 0, height: 600 }, |
| 94 | ), |
| 95 | ).toBe("intro"); |
| 96 | }); |
| 97 | }); |