From b73c1cd3a954d13f358f46a9e74c3f53ed9356e4 Mon Sep 17 00:00:00 2001 From: chengpeiquan Date: Sun, 8 Mar 2026 23:53:13 +0800 Subject: [PATCH] test: add preserveMarkup unit coverage for markup helpers --- .../2026-03-08-coverage-99-plus-design.md | 44 ++ docs/plans/2026-03-08-coverage-99-plus.md | 71 +++ test/Markup.spec.tsx | 584 ++++++++++++++++++ 3 files changed, 699 insertions(+) create mode 100644 docs/plans/2026-03-08-coverage-99-plus-design.md create mode 100644 docs/plans/2026-03-08-coverage-99-plus.md create mode 100644 test/Markup.spec.tsx diff --git a/docs/plans/2026-03-08-coverage-99-plus-design.md b/docs/plans/2026-03-08-coverage-99-plus-design.md new file mode 100644 index 0000000..40ac7cc --- /dev/null +++ b/docs/plans/2026-03-08-coverage-99-plus-design.md @@ -0,0 +1,44 @@ +# Coverage 99+ Design + +**Goal:** Raise unit-test coverage from the current post-fix level to 99%+ without changing production behavior. + +**Context:** The recent `preserveMarkup` work added substantial new logic under `src/Truncate/engines/markup.tsx` and `src/Truncate/markup/render.tsx`. CI focused on browser E2E coverage, so Codecov correctly reported a project-level drop. A first recovery pass already moved local coverage to `98.41%`, leaving only a handful of defensive branches uncovered. + +**Recommended Approach:** Add focused unit tests only, centered in `test/Markup.spec.tsx`, and avoid any production refactor. This keeps the fix surgical, preserves behavior, and directly addresses the Codecov signal instead of muting it. + +## Options Considered + +### Option A: Add focused helper and engine tests +- Keep all changes in tests +- Target the remaining uncovered branches directly +- Fastest path to 99%+ + +**Trade-off:** A few tests will exercise fairly defensive branches, so they are slightly more synthetic than top-level component tests. + +### Option B: Refactor helper internals for easier direct testing +- Could make each branch easier to target +- Might reduce test setup complexity + +**Trade-off:** Changes production code purely for coverage, which is unnecessary for this CI repair. + +### Option C: Relax or reshape coverage rules +- Fast to make CI green + +**Trade-off:** Hides a real test gap and weakens the project signal. Rejected. + +## Design + +### Scope +- Modify only `test/Markup.spec.tsx` +- Do not modify `src/**` unless a genuine bug is discovered +- Keep existing E2E coverage unchanged + +### Test strategy +- Cover the remaining helper branches in `render.tsx` +- Cover the remaining defensive cleanup branches in `markup.tsx` +- Reuse the current measurement sandbox so tests stay close to the real `preserveMarkup` execution path + +### Success criteria +- `pnpm vitest run test/Markup.spec.tsx` passes +- `pnpm coverage` reports total coverage at `99%+` +- No production behavior changes diff --git a/docs/plans/2026-03-08-coverage-99-plus.md b/docs/plans/2026-03-08-coverage-99-plus.md new file mode 100644 index 0000000..4669918 --- /dev/null +++ b/docs/plans/2026-03-08-coverage-99-plus.md @@ -0,0 +1,71 @@ +# Coverage 99+ Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Raise project unit-test coverage to 99%+ by adding focused tests for the remaining uncovered `preserveMarkup` helper branches. + +**Architecture:** Keep production code unchanged and extend the existing focused spec `test/Markup.spec.tsx`. Use narrowly scoped assertions that target the remaining uncovered defensive branches in the snapshot/render helpers and markup truncation engine, then verify with the existing `vitest` coverage workflow. + +**Tech Stack:** React, TypeScript, Vitest, Testing Library, Sinon, Happy DOM + +--- + +### Task 1: Cover remaining render helper branches + +**Files:** +- Modify: `test/Markup.spec.tsx` +- Reference: `src/Truncate/markup/render.tsx` + +**Step 1: Write the failing tests** +- Add tests for `sliceSnapshotNodes` with zero or fractional remaining width. +- Add tests for `trimLeadingWhitespace` when the first node is a line break. +- Add tests for any still-uncovered whitespace handling branches. + +**Step 2: Run test to verify it fails or expands coverage needfully** +Run: `pnpm vitest run test/Markup.spec.tsx` +Expected: Existing suite stays green or a new assertion fails for the uncovered branch. + +**Step 3: Write minimal test-only implementation** +- Adjust only test fixtures/assertions until the branch is exercised correctly. + +**Step 4: Run test to verify it passes** +Run: `pnpm vitest run test/Markup.spec.tsx` +Expected: PASS + +### Task 2: Cover remaining markup engine defensive branches + +**Files:** +- Modify: `test/Markup.spec.tsx` +- Reference: `src/Truncate/engines/markup.tsx` + +**Step 1: Write the failing tests** +- Add a test that forces recursive cleanup of whitespace-only text and empty elements. +- Add a test that exercises unsupported child-node cleanup in the truncation clone path. + +**Step 2: Run test to verify it fails or expands coverage needfully** +Run: `pnpm vitest run test/Markup.spec.tsx` +Expected: Targeted assertions prove the branch is now reached. + +**Step 3: Write minimal test-only implementation** +- Keep the change isolated to fixture setup and expectations in `test/Markup.spec.tsx`. + +**Step 4: Run test to verify it passes** +Run: `pnpm vitest run test/Markup.spec.tsx` +Expected: PASS + +### Task 3: Verify project coverage + +**Files:** +- Verify: `test/Markup.spec.tsx` + +**Step 1: Run focused tests** +Run: `pnpm vitest run test/Markup.spec.tsx` +Expected: PASS + +**Step 2: Run full coverage** +Run: `pnpm coverage` +Expected: Total coverage reaches at least `99%` + +**Step 3: Review result** +- Confirm no production files changed. +- Confirm only test/docs plan files were touched. diff --git a/test/Markup.spec.tsx b/test/Markup.spec.tsx new file mode 100644 index 0000000..ad45aac --- /dev/null +++ b/test/Markup.spec.tsx @@ -0,0 +1,584 @@ +import { render } from '@testing-library/react' +import React from 'react' +import sinon from 'sinon' +import { getMarkupTruncation } from '@/Truncate/engines/markup' +import { + getSnapshotTextLength, + renderMarkupLines, + renderMarkupPrefix, + renderSnapshotNodes, + sliceSnapshotNodes, + trimLeadingWhitespace, + trimTrailingWhitespace, +} from '@/Truncate/markup/render' +import { + type MarkupSnapshotNode, + createMarkupSnapshot, +} from '@/Truncate/markup/snapshot' +import { separator } from './config/test-config' + +const createRect = (width = 0, height = 0) => { + const rect = new DOMRect() + rect.width = width + rect.height = height + return rect +} + +const renderMarkup = (content: React.ReactNode) => { + return render(
{content}
) +} + +const createSnapshotFromHtml = (html: string) => { + const root = document.createElement('span') + root.innerHTML = html + return createMarkupSnapshot(root, separator) +} + +const createStyleDeclaration = (styles: Record) => { + return styles as unknown as CSSStyleDeclaration +} + +const setupMeasurementSandbox = ({ + parentWidth = 100, + parentPadding = 0, + parentBorder = 0, + singleLineHeight = 10, + zeroFullContent = false, + zeroSingleLine = false, +}: { + parentBorder?: number + parentPadding?: number + parentWidth?: number + singleLineHeight?: number + zeroFullContent?: boolean + zeroSingleLine?: boolean +} = {}) => { + const parent = document.createElement('div') + const rootNode = document.createElement('span') + const node = document.createElement('span') + + parent.appendChild(rootNode) + rootNode.appendChild(node) + document.body.appendChild(parent) + + sinon + .stub(parent, 'getBoundingClientRect') + .returns(createRect(parentWidth, 0)) + + sinon.stub(window, 'getComputedStyle').callsFake((element: Element) => { + if (element === parent) { + return createStyleDeclaration({ + borderLeftWidth: `${parentBorder}px`, + borderRightWidth: `${parentBorder}px`, + paddingLeft: `${parentPadding}px`, + paddingRight: `${parentPadding}px`, + }) + } + + return createStyleDeclaration({ + direction: 'ltr', + font: '16px Arial', + fontFamily: 'Arial', + fontSize: '16px', + fontStyle: 'normal', + fontWeight: '400', + letterSpacing: '0px', + lineHeight: `${singleLineHeight}px`, + overflowWrap: 'break-word', + textIndent: '0px', + textTransform: 'none', + whiteSpace: 'normal', + wordBreak: 'normal', + wordSpacing: '0px', + }) + }) + + sinon + .stub(HTMLSpanElement.prototype, 'getBoundingClientRect') + .callsFake(function (this: HTMLSpanElement) { + if (this.getAttribute('aria-hidden') !== 'true') { + return createRect(0, 0) + } + + const width = Number.parseFloat(this.style.width || `${parentWidth}`) + + if ((this.textContent || '') === 'A') { + return createRect(width, zeroSingleLine ? 0 : singleLineHeight) + } + + if (zeroFullContent) { + return createRect(width, 0) + } + + const charactersPerLine = Math.max(1, Math.floor(width / 10)) + const textLength = this.textContent?.length || 0 + const lineBreakCount = this.querySelectorAll('br').length + const lineCount = Math.max( + 1, + lineBreakCount + Math.ceil(textLength / charactersPerLine), + ) + + return createRect(width, lineCount * singleLineHeight) + }) + + return { node, parent, rootNode } +} + +describe('markup helpers', () => { + afterEach(() => { + sinon.restore() + document.body.innerHTML = '' + }) + + it('creates a snapshot for text, line breaks, elements, and ignores unsupported nodes', () => { + const host = document.createElement('span') + + host.append('Hello\n') + + const link = document.createElement('a') + link.setAttribute('class', 'rich-link') + link.setAttribute('data-id', '1') + link.append('world') + host.append(link) + host.append(document.createComment('ignore me')) + host.append(document.createElement('br')) + + const emphasis = document.createElement('em') + emphasis.append('!') + host.append(emphasis) + + expect(createMarkupSnapshot(host, separator)).toEqual([ + { text: 'Hello ', type: 'text' }, + { + attributes: { class: 'rich-link', 'data-id': '1' }, + children: [{ text: 'world', type: 'text' }], + tagName: 'a', + type: 'element', + }, + { type: 'line-break' }, + { + attributes: {}, + children: [{ text: '!', type: 'text' }], + tagName: 'em', + type: 'element', + }, + ]) + }) + + it('renders snapshot nodes with normalized attributes and skips empty elements', () => { + const nodes: MarkupSnapshotNode[] = [ + { + attributes: { + class: 'rich-link', + href: '/docs', + style: 'color: red; background-color: blue; border-left-width: 1px;', + }, + children: [{ text: 'Read docs', type: 'text' }], + tagName: 'a', + type: 'element', + }, + { type: 'line-break' }, + { + attributes: { class: 'ignored-node' }, + children: [], + tagName: 'span', + type: 'element', + }, + ] + + const { container } = renderMarkup(renderSnapshotNodes(nodes)) + + const link = container.querySelector('a[href="/docs"]') + expect(link).toBeInTheDocument() + expect(link).toHaveClass('rich-link') + expect(link).toHaveStyle({ + backgroundColor: 'blue', + borderLeftWidth: '1px', + color: 'red', + }) + expect(container.querySelectorAll('br')).toHaveLength(1) + expect(container.querySelector('.ignored-node')).toBeNull() + }) + + it('slices nested snapshots and tracks the remaining nodes', () => { + const nodes: MarkupSnapshotNode[] = [ + { + attributes: {}, + children: [{ text: 'hello', type: 'text' }], + tagName: 'strong', + type: 'element', + }, + { type: 'line-break' }, + { text: 'world', type: 'text' }, + ] + + expect(sliceSnapshotNodes(nodes, 2)).toEqual({ + remaining: 0, + rest: [ + { + attributes: {}, + children: [{ text: 'llo', type: 'text' }], + tagName: 'strong', + type: 'element', + }, + { type: 'line-break' }, + { text: 'world', type: 'text' }, + ], + taken: [ + { + attributes: {}, + children: [{ text: 'he', type: 'text' }], + tagName: 'strong', + type: 'element', + }, + ], + }) + }) + + it('returns untouched rest nodes when slicing starts with no remaining space', () => { + const nodes: MarkupSnapshotNode[] = [{ text: 'hello', type: 'text' }] + + expect(sliceSnapshotNodes(nodes, 0)).toEqual({ + remaining: 0, + rest: nodes, + taken: [], + }) + }) + + it('keeps a line-break in the rest set when less than one character is available', () => { + expect(sliceSnapshotNodes([{ type: 'line-break' }], 0.5)).toEqual({ + remaining: 0.5, + rest: [{ type: 'line-break' }], + taken: [], + }) + }) + + it('trims leading and trailing whitespace through nested elements', () => { + const nodes: MarkupSnapshotNode[] = [ + { + attributes: {}, + children: [{ text: ' ', type: 'text' }], + tagName: 'span', + type: 'element', + }, + { + attributes: {}, + children: [{ text: ' hello', type: 'text' }], + tagName: 'strong', + type: 'element', + }, + { text: ' world ', type: 'text' }, + { type: 'line-break' }, + ] + + expect(trimLeadingWhitespace(nodes)).toEqual([ + { + attributes: {}, + children: [{ text: 'hello', type: 'text' }], + tagName: 'strong', + type: 'element', + }, + { text: ' world ', type: 'text' }, + { type: 'line-break' }, + ]) + + expect(trimTrailingWhitespace(nodes)).toEqual([ + { + attributes: {}, + children: [{ text: ' ', type: 'text' }], + tagName: 'span', + type: 'element', + }, + { + attributes: {}, + children: [{ text: ' hello', type: 'text' }], + tagName: 'strong', + type: 'element', + }, + { text: ' world', type: 'text' }, + ]) + }) + + it('leaves leading line breaks untouched when trimming leading whitespace', () => { + const nodes: MarkupSnapshotNode[] = [ + { type: 'line-break' }, + { text: ' hello', type: 'text' }, + ] + + expect(trimLeadingWhitespace(nodes)).toEqual(nodes) + }) + + it('counts snapshot text length across text, line breaks, and nested elements', () => { + const nodes = createSnapshotFromHtml('Hi there
!') + expect(getSnapshotTextLength(nodes)).toBe(10) + }) + + it('renders markup lines and preserves empty intermediate lines', () => { + const nodes = createSnapshotFromHtml('Hello world') + + const { container } = renderMarkup( + renderMarkupLines(nodes, ['Hello', '', 'world'], ), + ) + + expect(container.querySelectorAll('br')).toHaveLength(2) + expect(container.querySelector('strong')).toHaveTextContent('world') + expect(container.textContent).toBe('Helloworld…') + }) + + it('renders a markup prefix and trims trailing whitespace before the ellipsis', () => { + const nodes = createSnapshotFromHtml('Hello world ') + + const { container } = renderMarkup( + renderMarkupPrefix(nodes, 13, , true), + ) + + expect(container.textContent).toBe('Hello world…') + expect(container.querySelector('strong')).toHaveTextContent('world') + }) + + it('drops whitespace-only trailing nodes and keeps the untrimmed prefix when requested', () => { + const whitespaceOnlyText: MarkupSnapshotNode[] = [ + { text: 'hello', type: 'text' }, + { text: ' ', type: 'text' }, + ] + + expect(trimTrailingWhitespace(whitespaceOnlyText)).toEqual([ + { text: 'hello', type: 'text' }, + ]) + + const whitespaceOnlyElement: MarkupSnapshotNode[] = [ + { text: 'hello', type: 'text' }, + { + attributes: {}, + children: [{ text: ' ', type: 'text' }], + tagName: 'span', + type: 'element', + }, + ] + + expect(trimTrailingWhitespace(whitespaceOnlyElement)).toEqual([ + { text: 'hello', type: 'text' }, + ]) + + const { container } = renderMarkup( + renderMarkupPrefix(createSnapshotFromHtml('hello '), 8, ), + ) + + expect(container.textContent).toBe('hello …') + }) +}) + +describe('getMarkupTruncation', () => { + afterEach(() => { + sinon.restore() + document.body.innerHTML = '' + }) + + it('returns no truncation when there is no markup node', () => { + const result = getMarkupTruncation({ + ellipsis: '…', + ellipsisNode: null, + fallbackDidTruncate: false, + fallbackVisibleTextLines: [], + lines: 1, + node: null, + rootNode: null, + separator, + trimWhitespace: false, + }) + + expect(result).toEqual({ didTruncate: false, result: null }) + }) + + it('falls back to plain-text lines when single-line measurement is unavailable', () => { + const { node, rootNode } = setupMeasurementSandbox({ zeroSingleLine: true }) + node.innerHTML = 'Hello world again' + + const result = getMarkupTruncation({ + ellipsis: , + ellipsisNode: null, + fallbackDidTruncate: true, + fallbackVisibleTextLines: ['Hello world'], + lines: 1, + node, + rootNode, + separator, + trimWhitespace: false, + }) + + expect(result.didTruncate).toBe(true) + + const { container } = renderMarkup(result.result) + expect(container.textContent).toBe('Hello world…') + expect(container.querySelector('strong')).toHaveTextContent('world') + }) + + it('falls back safely when content measurement returns zero height', () => { + const node = document.createElement('span') + node.innerHTML = 'Hello world' + document.body.appendChild(node) + + setupMeasurementSandbox({ zeroFullContent: true }) + + const result = getMarkupTruncation({ + ellipsis: '…', + ellipsisNode: null, + fallbackDidTruncate: false, + fallbackVisibleTextLines: [], + lines: 1, + node, + rootNode: null, + separator, + trimWhitespace: false, + }) + + expect(result).toEqual({ didTruncate: false, result: null }) + }) + + it('keeps the original markup when the content already fits', () => { + const { node, rootNode } = setupMeasurementSandbox({ parentWidth: 300 }) + node.innerHTML = 'Hello world' + + const result = getMarkupTruncation({ + ellipsis: '…', + ellipsisNode: null, + fallbackDidTruncate: true, + fallbackVisibleTextLines: ['ignored'], + lines: 2, + node, + rootNode, + separator, + trimWhitespace: false, + }) + + expect(result).toEqual({ didTruncate: false, result: null }) + }) + + it('returns no truncation when the snapshot contains no visible text', () => { + const { node, rootNode } = setupMeasurementSandbox() + node.innerHTML = '' + + const result = getMarkupTruncation({ + ellipsis: '…', + ellipsisNode: null, + fallbackDidTruncate: true, + fallbackVisibleTextLines: ['ignored'], + lines: 1, + node, + rootNode, + separator, + trimWhitespace: false, + }) + + expect(result).toEqual({ didTruncate: false, result: null }) + }) + + it('truncates rich content, clones the ellipsis markup, and trims break-only tails', () => { + const { node, rootNode } = setupMeasurementSandbox({ + parentBorder: 1, + parentPadding: 4, + parentWidth: 90, + }) + node.innerHTML = 'Hello

world again' + + const ellipsisNode = document.createElement('span') + ellipsisNode.innerHTML = '' + + const result = getMarkupTruncation({ + ellipsis: , + ellipsisNode, + fallbackDidTruncate: true, + fallbackVisibleTextLines: ['fallback'], + lines: 1, + node, + rootNode, + separator, + trimWhitespace: true, + }) + + expect(result.didTruncate).toBe(true) + + const { container } = renderMarkup(result.result) + expect(container.querySelector('a[href="/more"]')).toBeInTheDocument() + expect(container.textContent).toBe('Hello…') + expect(container.querySelectorAll('br')).toHaveLength(0) + }) + + it('removes whitespace-only trailing text before appending the ellipsis', () => { + const { node, rootNode } = setupMeasurementSandbox({ parentWidth: 30 }) + node.append('Hi there') + + const result = getMarkupTruncation({ + ellipsis: '…', + ellipsisNode: null, + fallbackDidTruncate: true, + fallbackVisibleTextLines: ['fallback'], + lines: 1, + node, + rootNode, + separator, + trimWhitespace: true, + }) + + expect(result.didTruncate).toBe(true) + + const { container } = renderMarkup(result.result) + expect(container.textContent).toBe('Hi…') + }) + + it('removes whitespace-only trailing elements before appending the ellipsis', () => { + const { node, rootNode } = setupMeasurementSandbox({ parentWidth: 30 }) + node.append('Hi') + + const trailingWhitespace = document.createElement('span') + trailingWhitespace.append(' ') + node.append(trailingWhitespace) + node.append('there') + + const result = getMarkupTruncation({ + ellipsis: '…', + ellipsisNode: null, + fallbackDidTruncate: true, + fallbackVisibleTextLines: ['fallback'], + lines: 1, + node, + rootNode, + separator, + trimWhitespace: true, + }) + + expect(result.didTruncate).toBe(true) + + const { container } = renderMarkup(result.result) + expect(container.textContent).toBe('Hi…') + expect(container.querySelector('span')).toBeNull() + }) + + it('removes emptied child elements and unsupported nodes while truncating', () => { + const { node, rootNode } = setupMeasurementSandbox({ parentWidth: 30 }) + node.append('H') + + const emptyTail = document.createElement('span') + emptyTail.append(document.createComment('drop me')) + node.append(emptyTail) + node.append('i123') + + const result = getMarkupTruncation({ + ellipsis: '…', + ellipsisNode: null, + fallbackDidTruncate: true, + fallbackVisibleTextLines: ['fallback'], + lines: 1, + node, + rootNode, + separator, + trimWhitespace: false, + }) + + expect(result.didTruncate).toBe(true) + + const { container } = renderMarkup(result.result) + expect(container.textContent).toBe('Hi1…') + expect(container.querySelector('span')).toBeNull() + }) +})