Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,62 @@ describe('ApiDocLoader', () => {
const loader = createLoadedLoader();
expect(loader.get('react', 'IgxGridComponent')).toBeUndefined();
});

it('falls back to a case-insensitive match', () => {
const loader = createLoadedLoader();
expect(loader.get('angular', 'igxgridcomponent')?.component).toBe('IgxGridComponent');
});

describe('generic-typed names', () => {
const GENERIC_CONTENT = [
'### [IgbCombo<T>](https://example.com/blazor/igbcombo)',
'',
'A combo box component.',
'',
'### [DynamicContentInfo<T>](https://example.com/blazor/dci-generic)',
'',
'Generic variant.',
'',
'### [DynamicContentInfo](https://example.com/blazor/dci)',
'',
'Non-generic variant.',
'',
].join('\n');

function createBlazorLoader(): ApiDocLoader {
setupFsMocks();
mockReadFileSync.mockReturnValue(GENERIC_CONTENT);
const loader = new ApiDocLoader([{ ...FIXTURE_CONFIG, key: 'blazor', displayName: 'Blazor' }]);
loader.load();
return loader;
}

it('resolves the plain class name to the generic-typed entry', () => {
const loader = createBlazorLoader();
expect(loader.get('blazor', 'IgbCombo')?.component).toBe('IgbCombo<T>');
});

it('still resolves the exact generic-typed name', () => {
const loader = createBlazorLoader();
expect(loader.get('blazor', 'IgbCombo<T>')?.component).toBe('IgbCombo<T>');
});

it('resolves a differently-parameterised spelling to the same entry', () => {
const loader = createBlazorLoader();
expect(loader.get('blazor', 'IgbCombo<string>')?.component).toBe('IgbCombo<T>');
});

it('combines case-insensitive and generic-stripped matching', () => {
const loader = createBlazorLoader();
expect(loader.get('blazor', 'igbcombo')?.component).toBe('IgbCombo<T>');
});

it('prefers the exact non-generic entry when both spellings are indexed', () => {
const loader = createBlazorLoader();
expect(loader.get('blazor', 'DynamicContentInfo')?.component).toBe('DynamicContentInfo');
expect(loader.get('blazor', 'DynamicContentInfo<T>')?.component).toBe('DynamicContentInfo<T>');
});
});
});

describe('search()', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest';
import { applyDocAlias, normalizeDocName, sanitizeSearchDocsQuery } from '../../tools/doc-tools.js';
import {
applyCompactGridPrefix,
applyDocAlias,
formatSubstitutionNotice,
normalizeDocName,
parseDocNames,
resolveDoc,
sanitizeSearchDocsQuery,
} from '../../tools/doc-tools.js';
import type { DocsProvider } from '../../providers/DocsProvider.js';

describe('sanitizeSearchDocsQuery', () => {
it('quotes plain terms with AND (implicit in FTS4)', () => {
Expand Down Expand Up @@ -81,6 +90,32 @@ describe('sanitizeSearchDocsQuery', () => {
it('handles realistic user query with special chars injected', () => {
expect(sanitizeSearchDocsQuery('grid" OR "1=1')).toBe('"grid" "OR" "1=1"');
});

it('strips natural-language stopwords (how do I ...)', () => {
expect(sanitizeSearchDocsQuery('how do I enable row editing')).toBe(
'"enable" "row" "editing"',
);
});

it('strips a leading article', () => {
expect(sanitizeSearchDocsQuery('the grid selection')).toBe('"grid" "selection"');
});

it('keeps and/or/but as ordinary terms (not stopwords)', () => {
expect(sanitizeSearchDocsQuery('drag and drop')).toBe('"drag" "and" "drop"');
});

it('falls back to full terms when every term is a stopword', () => {
expect(sanitizeSearchDocsQuery('how do I')).toBe('"how" "do" "I"');
});

it('falls back to full terms when the non-stopwords are all invalid', () => {
expect(sanitizeSearchDocsQuery('* the')).toBe('"the"');
});

it('does not strip meaningful component words that resemble nothing in the list', () => {
expect(sanitizeSearchDocsQuery('column pinning')).toBe('"column" "pinning"');
});
});

describe('normalizeDocName', () => {
Expand Down Expand Up @@ -127,6 +162,26 @@ describe('normalizeDocName', () => {
it('falls back to lowercased input when normalization yields empty string', () => {
expect(normalizeDocName('Igx')).toBe('igx');
});

it('kebab-cases a spaced multi-word name', () => {
expect(normalizeDocName('date picker')).toBe('date-picker');
});

it('kebab-cases a three-word name', () => {
expect(normalizeDocName('navigation drawer panel')).toBe('navigation-drawer-panel');
});

it('collapses multiple spaces and trims', () => {
expect(normalizeDocName(' tree grid ')).toBe('tree-grid');
});

it('converts underscores to hyphens', () => {
expect(normalizeDocName('color_editor')).toBe('color-editor');
});

it('leaves a class name equivalent to its spaced form', () => {
expect(normalizeDocName('IgxDatePicker')).toBe(normalizeDocName('date picker'));
});
});

describe('applyDocAlias', () => {
Expand Down Expand Up @@ -195,3 +250,246 @@ describe('applyDocAlias', () => {
expect(applyDocAlias('react', normalized)).toBe('overview');
});
});

describe('resolveDoc', () => {
// Stub provider backed by a fixed set of known doc filenames. searchDocs
// returns the LocalDocsProvider-style markdown (all known docs, in insertion
// order = rank order) so the fallback parser, guard, and iteration are exercised.
function makeProvider(known: Record<string, string>): DocsProvider {
return {
async listComponents() {
return '';
},
async getDoc(_framework: string, name: string) {
const key = name.replace(/\.md$/, '');
return key in known
? { text: known[key], found: true }
: { text: 'not found', found: false };
},
async searchDocs(_framework: string, _query: string) {
const keys = Object.keys(known);
if (keys.length === 0) return 'No results';
return keys.map((k) => `- **X** (\`${k}\`)`).join('\n');
},
};
}

it('resolves a direct name', async () => {
const p = makeProvider({ accordion: 'ACC' });
const r = await resolveDoc(p, 'angular', 'accordion');
expect(r).toMatchObject({ found: true, servedName: 'accordion', text: 'ACC' });
});

it('applies the grid- prefix fallback for bare feature names', async () => {
const p = makeProvider({ 'grid-sorting': 'SORT' });
const r = await resolveDoc(p, 'angular', 'sorting');
expect(r).toMatchObject({ found: true, servedName: 'grid-sorting' });
});

it('falls back to search when the name does not resolve mechanically', async () => {
const p = makeProvider({ navdrawer: 'NAV' });
const r = await resolveDoc(p, 'angular', 'navigation drawer');
expect(r.found).toBe(true);
expect(r.servedName).toBe('navdrawer');
expect(r.text).toBe('NAV');
});


it('resolves through the search fallback when the provider returns remote-format results', async () => {
// RemoteDocsProvider proxies DocsController.cs, which renders each hit as
// `**doc-name** [Component]` followed by an excerpt line — no backticks.
const p: DocsProvider = {
async listComponents() {
return '';
},
async getDoc(_framework: string, name: string) {
return name === 'navdrawer'
? { text: 'NAV', found: true }
: { text: 'not found', found: false };
},
async searchDocs() {
return [
'**navdrawer** [IgxNavigationDrawer]',
'The **Navigation Drawer** slides in from the side...',
'',
'**grid-sorting** [IgxGrid]',
'Sort rows by column.',
].join('\n');
},
};
const r = await resolveDoc(p, 'angular', 'navigation drawer');
expect(r).toMatchObject({ found: true, servedName: 'navdrawer', text: 'NAV', fuzzy: true });
});
it('returns not found when search also yields nothing', async () => {
const p = makeProvider({}); // searchDocs returns "No results"
const r = await resolveDoc(p, 'angular', 'totally unknown widget');
expect(r.found).toBe(false);
});

it('returns the original not-found result when the search fallback throws', async () => {
const p: DocsProvider = {
...makeProvider({}),
async searchDocs() {
throw new Error('Backend returned 500: boom');
},
};
const r = await resolveDoc(p, 'angular', 'totally unknown widget');
expect(r).toMatchObject({ found: false, fuzzy: false, text: 'not found' });
});

it('rejects an unrelated search hit that shares no token with the request', async () => {
const p = makeProvider({ 'grid-paste-excel': 'X' });
const r = await resolveDoc(p, 'angular', 'textarea');
expect(r.found).toBe(false); // guard rejects; better an honest miss than a wrong doc
});

it('matches search hits case-insensitively against the request', async () => {
const p = makeProvider({ 'zoomSlider-overview': 'ZS' });
const r = await resolveDoc(p, 'angular', 'zoomslider');
expect(r).toMatchObject({ found: true, servedName: 'zoomSlider-overview', fuzzy: true });
});

it('skips an unrelated top hit and accepts a lower-ranked one that shares a token', async () => {
// Top hit unrelated; second hit shares the "drawer" token.
const p = makeProvider({ 'grid-paste-excel': 'X', navdrawer: 'NAV' });
const r = await resolveDoc(p, 'angular', 'navigation drawer');
expect(r).toMatchObject({ found: true, servedName: 'navdrawer', text: 'NAV' });
});

it('accepts a feature doc that shares the component token', async () => {
const p = makeProvider({ 'treegrid-export-excel': 'T' });
const r = await resolveDoc(p, 'angular', 'treegrid');
expect(r).toMatchObject({ found: true, servedName: 'treegrid-export-excel' });
});

it('rewrites an angular tree-grid- topic name to the compact doc key', async () => {
const p = makeProvider({ 'treegrid-filtering': 'TF' });
const r = await resolveDoc(p, 'angular', 'tree-grid-filtering');
expect(r).toMatchObject({ found: true, servedName: 'treegrid-filtering', fuzzy: false });
});

it('rewrites angular hierarchical-grid- and pivot-grid- topic names', async () => {
const p = makeProvider({ 'hierarchicalgrid-paging': 'HP', 'pivotGrid-state-persistence': 'PS' });
await expect(resolveDoc(p, 'angular', 'hierarchical-grid-paging')).resolves.toMatchObject({
found: true,
servedName: 'hierarchicalgrid-paging',
});
await expect(resolveDoc(p, 'angular', 'pivot-grid-state-persistence')).resolves.toMatchObject({
found: true,
servedName: 'pivotGrid-state-persistence',
fuzzy: false,
});
});

it('prefers the exact compact doc over a related search hit', async () => {
// Regression: "tree-grid-editing" used to fall through to search and serve
// treegrid-batch-editing, which covers a different feature.
const p = makeProvider({ 'treegrid-batch-editing': 'BATCH', 'treegrid-editing': 'EDIT' });
const r = await resolveDoc(p, 'angular', 'tree-grid-editing');
expect(r).toMatchObject({ found: true, servedName: 'treegrid-editing', text: 'EDIT', fuzzy: false });
});

it('does not rewrite grid prefixes for non-angular frameworks', async () => {
// React keys these docs with the hyphenated form; a rewrite would break them.
const p = makeProvider({ 'tree-grid-filtering': 'TF' });
const r = await resolveDoc(p, 'react', 'tree-grid-filtering');
expect(r).toMatchObject({ found: true, servedName: 'tree-grid-filtering' });
});

it('marks a search-fallback resolution as fuzzy', async () => {
const p = makeProvider({ navdrawer: 'NAV' });
const r = await resolveDoc(p, 'angular', 'navigation drawer');
expect(r.fuzzy).toBe(true);
});

it('does not mark deterministic resolutions as fuzzy', async () => {
const direct = await resolveDoc(makeProvider({ accordion: 'ACC' }), 'angular', 'accordion');
expect(direct.fuzzy).toBe(false);

const aliased = await resolveDoc(makeProvider({ 'grid-grid': 'G' }), 'angular', 'IgxGrid');
expect(aliased).toMatchObject({ found: true, fuzzy: false });

const gridPrefixed = await resolveDoc(makeProvider({ 'grid-sorting': 'S' }), 'angular', 'sorting');
expect(gridPrefixed).toMatchObject({ found: true, fuzzy: false });
});

it('is not fuzzy when nothing was found at all', async () => {
const r = await resolveDoc(makeProvider({}), 'angular', 'totally unknown widget');
expect(r).toMatchObject({ found: false, fuzzy: false });
});
});

describe('applyCompactGridPrefix', () => {
it('rewrites the three angular grid-variant prefixes', () => {
expect(applyCompactGridPrefix('angular', 'tree-grid-filtering')).toBe('treegrid-filtering');
expect(applyCompactGridPrefix('angular', 'hierarchical-grid-paging')).toBe('hierarchicalgrid-paging');
expect(applyCompactGridPrefix('angular', 'pivot-grid-sorting')).toBe('pivotGrid-sorting');
});

it('returns null when no prefix matches', () => {
expect(applyCompactGridPrefix('angular', 'grid-sorting')).toBeNull();
expect(applyCompactGridPrefix('angular', 'accordion')).toBeNull();
});

it('returns null for the bare component name (no topic suffix)', () => {
expect(applyCompactGridPrefix('angular', 'tree-grid')).toBeNull();
});

it('returns null for non-angular frameworks', () => {
expect(applyCompactGridPrefix('react', 'tree-grid-filtering')).toBeNull();
expect(applyCompactGridPrefix('blazor', 'hierarchical-grid-paging')).toBeNull();
expect(applyCompactGridPrefix('webcomponents', 'pivot-grid-sorting')).toBeNull();
});

it('preserves multi-segment topics', () => {
expect(applyCompactGridPrefix('angular', 'tree-grid-column-moving')).toBe('treegrid-column-moving');
});
});

describe('formatSubstitutionNotice', () => {
it('names both the requested and the served doc', () => {
const notice = formatSubstitutionNotice('tree-grid-editing', 'treegrid-batch-editing');
expect(notice).toContain('`tree-grid-editing`');
expect(notice).toContain('`treegrid-batch-editing`');
});

it('points at the discovery tools', () => {
const notice = formatSubstitutionNotice('x', 'y');
expect(notice).toContain('list_components');
expect(notice).toContain('search_docs');
});
});

describe('parseDocNames', () => {
it('parses local-format results from the backtick token, not the bold toc title', () => {
const out = [
'Found 2 results for "sort" in **angular**:',
'',
'- **Grid Sorting** (`grid-sorting`)',
' Sort rows by one or more columns.',
'- **Tree Grid Sorting** (`treegrid-sorting`)',
].join('\n');
expect(parseDocNames(out)).toEqual(['grid-sorting', 'treegrid-sorting']);
});

it('parses remote-format results from the line-leading bold doc name', () => {
const out = [
'**grid-sorting** [IgxGrid]',
'Sort rows by one or more columns.',
'',
'**treegrid-sorting**',
'Tree grid sorting excerpt.',
].join('\n');
expect(parseDocNames(out)).toEqual(['grid-sorting', 'treegrid-sorting']);
});

it('ignores bold prose inside a remote excerpt', () => {
const out = ['**grid-editing** [IgxGrid]', '**Note:** editing requires primaryKey.'].join('\n');
expect(parseDocNames(out)).toEqual(['grid-editing']);
});

it('returns an empty list for a no-results message', () => {
expect(parseDocNames('No results found for "xyz".')).toEqual([]);
expect(parseDocNames('No results')).toEqual([]);
});
});
Loading
Loading