// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest';
import { findAnchorElementByNormalizedId, normalizeAnchorId } from '../anchor-id';
import { extractSections } from '../markdown-section-extractor';
import { getHashTargetElement } from '../same-page-hash-nav';
describe('normalizeAnchorId', () => {
it('drops the hyphen GitHub leaves behind a stripped emoji', () => {
expect(normalizeAnchorId('-getting-started')).toBe('getting-started');
expect(normalizeAnchorId('-architecture-diagrams')).toBe('architecture-diagrams');
});
it('collapses the hyphen run GitHub emits around a dropped "&"', () => {
expect(normalizeAnchorId('-community--support')).toBe('community-support');
});
it('is a no-op for an already-clean id', () => {
expect(normalizeAnchorId('table-of-contents')).toBe('table-of-contents');
});
it('normalizes raw heading text too', () => {
expect(normalizeAnchorId('π Getting Started')).toBe('getting-started');
});
});
describe('findAnchorElementByNormalizedId', () => {
beforeEach(() => {
document.body.innerHTML = `
OpenFrame CLI β Documentation
π Table of Contents
π Getting Started
A - B
`;
});
it('resolves a GitHub-style anchor onto our heading id', () => {
expect(findAnchorElementByNormalizedId('-getting-started', document)?.id).toBe('getting-started');
expect(findAnchorElementByNormalizedId('-table-of-contents', document)?.id).toBe('table-of-contents');
});
it('resolves the double-hyphen "&" form', () => {
expect(findAnchorElementByNormalizedId('-community--support', document)?.id).toBe('community-support');
});
it('matches an id whose own raw form normalizes differently', () => {
// `a---b` is not reachable by normalized lookup β only the heading scan finds it.
expect(findAnchorElementByNormalizedId('a-b', document)?.id).toBe('a---b');
});
it('returns null for an anchor with no heading behind it', () => {
expect(findAnchorElementByNormalizedId('nope', document)).toBeNull();
expect(findAnchorElementByNormalizedId('', document)).toBeNull();
});
});
describe('duplicate headings β GitHub numbers from -1, our deduper from -2', () => {
beforeEach(() => {
document.body.innerHTML = `
Setup
Setup
Setup
Step 2
`;
});
it('shifts a GitHub duplicate fragment onto our id', () => {
expect(getHashTargetElement('setup-1')?.id).toBe('setup-2');
expect(getHashTargetElement('setup-2')?.id).toBe('setup-2');
});
it('an id that genuinely ends in a number is matched exactly, never shifted', () => {
// `step-2` exists, so the exact lookup wins and `step-3` is never consulted.
expect(getHashTargetElement('step-2')?.id).toBe('step-2');
});
it('does not invent a match when the shifted id is absent too', () => {
expect(getHashTargetElement('missing-1')).toBeNull();
});
});
describe('getHashTargetElement β the fuzzy pass is wired into THE resolver', () => {
beforeEach(() => {
document.body.innerHTML = `
π Getting Started
encoded-id row
`;
});
it('exact ids still win, unchanged', () => {
expect(getHashTargetElement('getting-started')?.id).toBe('getting-started');
});
it('percent-decoding still runs before the fuzzy pass', () => {
expect(getHashTargetElement('ticket-a%2Fb')?.id).toBe('ticket-a/b');
});
it('falls back to a normalized match β the TOC bug, through the public entry point', () => {
expect(getHashTargetElement('-getting-started')?.id).toBe('getting-started');
});
it('still returns null when nothing matches', () => {
expect(getHashTargetElement('nope')).toBeNull();
expect(getHashTargetElement('')).toBeNull();
});
});
describe('the openframe-cli doc that reported the bug', () => {
// Verbatim shapes from
// GET /api/docs/sources/openframe-docs/content?path=openframe-cli
const headings = [
'# OpenFrame CLI β Documentation',
'## π Table of Contents',
'## π Getting Started',
'## π οΈ Development',
'## π¬ Community & Support',
].join('\n\n');
const tocHrefs = ['#-getting-started', '#-development', '#-community--support'];
it('every TOC href resolves to the id the section extractor emits', () => {
const sections = extractSections(headings);
document.body.innerHTML = sections.map(s => `${s.title}
`).join('');
const resolved = tocHrefs.map(href => getHashTargetElement(href.slice(1))?.id ?? null);
expect(resolved).toEqual(['getting-started', 'development', 'community-support']);
// β¦and none of them resolved before this helper existed:
expect(tocHrefs.map(href => document.getElementById(href.slice(1)))).toEqual([null, null, null]);
});
});