diff --git a/src/generators/addon-verify/utils/__tests__/generateFileList.test.mjs b/src/generators/addon-verify/utils/__tests__/generateFileList.test.mjs new file mode 100644 index 00000000..d32f1a6f --- /dev/null +++ b/src/generators/addon-verify/utils/__tests__/generateFileList.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { generateFileList } from '../generateFileList.mjs'; + +describe('generateFileList', () => { + it('should transform test.js files with updated require paths', () => { + const codeBlocks = [ + { + name: 'test.js', + content: "const addon = require('./build/Release/addon');", + }, + ]; + + const result = generateFileList(codeBlocks); + const testFile = result.find(file => file.name === 'test.js'); + + assert(testFile.content.includes("'use strict';")); + assert(testFile.content.includes('`./build/${common.buildType}/addon`')); + assert(!testFile.content.includes("'./build/Release/addon'")); + }); + + it('should preserve other files unchanged', () => { + const codeBlocks = [{ name: 'addon.cc', content: '#include ' }]; + + const result = generateFileList(codeBlocks); + + assert.equal( + result.find(file => file.name === 'addon.cc').content, + '#include ' + ); + }); + + it('should add binding.gyp file', () => { + const codeBlocks = [{ name: 'addon.cc', content: 'code' }]; + + const result = generateFileList(codeBlocks); + const bindingFile = result.find(file => file.name === 'binding.gyp'); + + assert(bindingFile); + const config = JSON.parse(bindingFile.content); + assert.equal(config.targets[0].target_name, 'addon'); + assert(config.targets[0].sources.includes('addon.cc')); + }); + + it('should handle empty input', () => { + const result = generateFileList([]); + + assert.equal(result.length, 1); + assert.equal(result[0].name, 'binding.gyp'); + }); +}); diff --git a/src/generators/addon-verify/utils/__tests__/section.test.mjs b/src/generators/addon-verify/utils/__tests__/section.test.mjs new file mode 100644 index 00000000..cfd1f2e0 --- /dev/null +++ b/src/generators/addon-verify/utils/__tests__/section.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + isBuildableSection, + normalizeSectionName, + generateSectionFolderName, +} from '../section.mjs'; + +describe('isBuildableSection', () => { + it('should return true when both .cc and .js files are present', () => { + const codeBlocks = [ + { name: 'addon.cc', content: 'C++ code' }, + { name: 'test.js', content: 'JS code' }, + ]; + + assert.equal(isBuildableSection(codeBlocks), true); + }); + + it('should return false when only .cc file is present', () => { + const codeBlocks = [{ name: 'addon.cc', content: 'C++ code' }]; + + assert.equal(isBuildableSection(codeBlocks), false); + }); + + it('should return false when only .js file is present', () => { + const codeBlocks = [{ name: 'test.js', content: 'JS code' }]; + + assert.equal(isBuildableSection(codeBlocks), false); + }); + + it('should return false for empty array', () => { + assert.equal(isBuildableSection([]), false); + }); +}); + +describe('normalizeSectionName', () => { + it('should convert to lowercase and replace spaces with underscores', () => { + assert.equal(normalizeSectionName('Hello World'), 'hello_world'); + }); + + it('should remove non-word characters', () => { + assert.equal(normalizeSectionName('Test-Section!@#'), 'testsection'); + }); + + it('should handle empty string', () => { + assert.equal(normalizeSectionName(''), ''); + }); + + it('should handle mixed cases and special characters', () => { + assert.equal( + normalizeSectionName('My Test & Example #1'), + 'my_test__example_1' + ); + }); +}); + +describe('generateSectionFolderName', () => { + it('should generate folder name with padded index', () => { + assert.equal(generateSectionFolderName('hello_world', 0), '01_hello_world'); + }); + + it('should pad single digit indices', () => { + assert.equal(generateSectionFolderName('test', 5), '06_test'); + }); + + it('should not pad double digit indices', () => { + assert.equal(generateSectionFolderName('example', 15), '16_example'); + }); + + it('should handle empty section name', () => { + assert.equal(generateSectionFolderName('', 0), '01_'); + }); +}); diff --git a/src/generators/legacy-json/utils/__tests__/buildHierarchy.test.mjs b/src/generators/legacy-json/utils/__tests__/buildHierarchy.test.mjs new file mode 100644 index 00000000..d6c6441a --- /dev/null +++ b/src/generators/legacy-json/utils/__tests__/buildHierarchy.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { findParent, buildHierarchy } from '../buildHierarchy.mjs'; + +describe('findParent', () => { + it('finds parent with lower depth', () => { + const entries = [{ heading: { depth: 1 } }, { heading: { depth: 2 } }]; + const parent = findParent(entries[1], entries, 0); + assert.equal(parent, entries[0]); + }); + + it('throws when no parent exists', () => { + const entries = [{ heading: { depth: 2 } }]; + assert.throws(() => findParent(entries[0], entries, -1)); + }); +}); + +describe('buildHierarchy', () => { + it('returns empty array for empty input', () => { + assert.deepEqual(buildHierarchy([]), []); + }); + + it('keeps root entries at top level', () => { + const entries = [{ heading: { depth: 1 } }, { heading: { depth: 1 } }]; + const result = buildHierarchy(entries); + assert.equal(result.length, 2); + }); + + it('nests children under parents', () => { + const entries = [{ heading: { depth: 1 } }, { heading: { depth: 2 } }]; + const result = buildHierarchy(entries); + + assert.equal(result.length, 1); + assert.equal(result[0].hierarchyChildren.length, 1); + assert.equal(result[0].hierarchyChildren[0], entries[1]); + }); + + it('handles multiple levels', () => { + const entries = [ + { heading: { depth: 1 } }, + { heading: { depth: 2 } }, + { heading: { depth: 3 } }, + ]; + const result = buildHierarchy(entries); + + assert.equal(result.length, 1); + assert.equal(result[0].hierarchyChildren[0].hierarchyChildren.length, 1); + }); +}); diff --git a/src/generators/legacy-json/utils/__tests__/parseList.test.mjs b/src/generators/legacy-json/utils/__tests__/parseList.test.mjs new file mode 100644 index 00000000..1806cb84 --- /dev/null +++ b/src/generators/legacy-json/utils/__tests__/parseList.test.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + transformTypeReferences, + extractPattern, + parseListItem, + parseList, +} from '../parseList.mjs'; + +describe('transformTypeReferences', () => { + it('replaces template syntax with curly braces', () => { + const result = transformTypeReferences('``'); + assert.equal(result, '{string}'); + }); + + it('normalizes multiple types', () => { + const result = transformTypeReferences('`` | ``'); + assert.equal(result, '{string|number}'); + }); +}); + +describe('extractPattern', () => { + it('extracts pattern and removes from text', () => { + const current = {}; + const result = extractPattern( + 'name: test description', + /name:\s*([^.\s]+)/, + 'name', + current + ); + + assert.equal(current.name, 'test'); + assert.equal(result, ' description'); + }); + + it('returns original text when pattern not found', () => { + const current = {}; + const result = extractPattern( + 'no match', + /missing:\s*([^.]+)/, + 'missing', + current + ); + + assert.equal(result, 'no match'); + assert.equal(current.missing, undefined); + }); +}); + +describe('parseListItem', () => { + it('parses basic list item', () => { + const child = { + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'param {string} description' }], + }, + ], + }; + + const result = parseListItem(child); + assert.equal(typeof result, 'object'); + assert.ok(result.textRaw); + }); + + it('identifies return items', () => { + const child = { + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'Returns: something' }], + }, + ], + }; + + const result = parseListItem(child); + assert.equal(result.name, 'return'); + }); +}); + +describe('parseList', () => { + it('processes property sections', () => { + const section = { type: 'property', name: 'test' }; + const nodes = [ + { + type: 'list', + children: [ + { + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: '{string} description' }], + }, + ], + }, + ], + }, + ]; + + parseList(section, nodes); + assert.ok(section.textRaw); + }); + + it('processes event sections', () => { + const section = { type: 'event' }; + const nodes = [ + { + type: 'list', + children: [ + { + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'param description' }], + }, + ], + }, + ], + }, + ]; + + parseList(section, nodes); + assert.ok(Array.isArray(section.params)); + }); +}); diff --git a/src/generators/legacy-json/utils/__tests__/parseSignature.test.mjs b/src/generators/legacy-json/utils/__tests__/parseSignature.test.mjs new file mode 100644 index 00000000..911ea16e --- /dev/null +++ b/src/generators/legacy-json/utils/__tests__/parseSignature.test.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import parseSignature, { + parseDefaultValue, + findParameter, + parseParameters, +} from '../parseSignature.mjs'; + +describe('parseDefaultValue', () => { + it('extracts default value', () => { + const [name, defaultVal] = parseDefaultValue('param=default'); + assert.equal(name, 'param'); + assert.equal(defaultVal, '=default'); + }); + + it('handles no default value', () => { + const [name, defaultVal] = parseDefaultValue('param'); + assert.equal(name, 'param'); + assert.equal(defaultVal, undefined); + }); +}); + +describe('findParameter', () => { + it('finds parameter by index', () => { + const params = [{ name: 'first' }, { name: 'second' }]; + const result = findParameter('first', 0, params); + assert.equal(result.name, 'first'); + }); + + it('searches by name when index fails', () => { + const params = [{ name: 'first' }, { name: 'second' }]; + const result = findParameter('second', 0, params); + assert.equal(result.name, 'second'); + }); + + it('finds in nested options', () => { + const params = [ + { + name: 'options', + options: [{ name: 'nested' }], + }, + ]; + const result = findParameter('nested', 0, params); + assert.equal(result.name, 'nested'); + }); + + it('returns default when not found', () => { + const result = findParameter('missing', 0, []); + assert.equal(result.name, 'missing'); + }); +}); + +describe('parseParameters', () => { + it('parses simple parameters', () => { + const declared = ['param1', 'param2']; + const markdown = [{ name: 'param1' }, { name: 'param2' }]; + const result = parseParameters(declared, markdown); + + assert.equal(result.length, 2); + assert.equal(result[0].name, 'param1'); + assert.equal(result[1].name, 'param2'); + }); + + it('handles default values', () => { + const declared = ['param=value']; + const markdown = [{ name: 'param' }]; + const result = parseParameters(declared, markdown); + + assert.equal(result[0].default, '=value'); + }); +}); + +describe('parseSignature', () => { + it('returns empty signature for no parameters', () => { + const result = parseSignature('`method()`', []); + assert.deepEqual(result.params, []); + }); + + it('extracts return value', () => { + const markdown = [{ name: 'return', type: 'string' }]; + const result = parseSignature('`method()`', markdown); + + assert.equal(result.return.name, 'return'); + assert.equal(result.return.type, 'string'); + }); + + it('parses method with parameters', () => { + const markdown = [{ name: 'param1' }, { name: 'param2' }]; + const result = parseSignature('`method(param1, param2)`', markdown); + + assert.equal(result.params.length, 2); + }); +}); diff --git a/src/generators/legacy-json/utils/buildHierarchy.mjs b/src/generators/legacy-json/utils/buildHierarchy.mjs index ed2b4143..f7ad87cd 100644 --- a/src/generators/legacy-json/utils/buildHierarchy.mjs +++ b/src/generators/legacy-json/utils/buildHierarchy.mjs @@ -6,7 +6,7 @@ * @param {number} startIdx * @returns {import('../types.d.ts').HierarchizedEntry} */ -function findParent(entry, entries, startIdx) { +export function findParent(entry, entries, startIdx) { // Base case: if we're at the beginning of the list, no valid parent exists. if (startIdx < 0) { throw new Error( diff --git a/src/generators/legacy-json/utils/parseList.mjs b/src/generators/legacy-json/utils/parseList.mjs index fe00775f..bf8cafea 100644 --- a/src/generators/legacy-json/utils/parseList.mjs +++ b/src/generators/legacy-json/utils/parseList.mjs @@ -14,7 +14,7 @@ import { transformNodesToString } from '../../../utils/unist.mjs'; * @param {string} string * @returns {string} */ -function transformTypeReferences(string) { +export function transformTypeReferences(string) { return string.replace(/`<([^>]+)>`/g, '{$1}').replaceAll('} | {', '|'); } @@ -26,7 +26,7 @@ function transformTypeReferences(string) { * @param {Object} current * @returns {string} */ -const extractPattern = (text, pattern, key, current) => { +export const extractPattern = (text, pattern, key, current) => { const match = text.match(pattern)?.[1]?.trim().replace(/\.$/, ''); if (!match) { @@ -43,7 +43,7 @@ const extractPattern = (text, pattern, key, current) => { * @param {import('@types/mdast').ListItem} child * @returns {import('../types').ParameterList} */ -function parseListItem(child) { +export function parseListItem(child) { const current = {}; // Extract and clean raw text from the node, excluding nested lists diff --git a/src/generators/legacy-json/utils/parseSignature.mjs b/src/generators/legacy-json/utils/parseSignature.mjs index 9c17289b..95af8b22 100644 --- a/src/generators/legacy-json/utils/parseSignature.mjs +++ b/src/generators/legacy-json/utils/parseSignature.mjs @@ -17,7 +17,7 @@ const updateDepth = (char, depth) => * @param {number} optionalDepth * @returns {[string, number, boolean]} */ -function parseNameAndOptionalStatus(parameterName, optionalDepth) { +export function parseNameAndOptionalStatus(parameterName, optionalDepth) { // Let's check if the parameter is optional & grab its name at the same time. // We need to see if there's any leading brackets in front of the parameter // name. While we're doing that, we can also get the index where the @@ -56,7 +56,7 @@ function parseNameAndOptionalStatus(parameterName, optionalDepth) { * @param {string} parameterName * @returns {[string, string | undefined]} */ -function parseDefaultValue(parameterName) { +export function parseDefaultValue(parameterName) { /** * @type {string | undefined} */ @@ -80,7 +80,7 @@ function parseDefaultValue(parameterName) { * @param {Array} markdownParameters * @returns {import('../types.d.ts').Parameter} */ -function findParameter(parameterName, index, markdownParameters) { +export function findParameter(parameterName, index, markdownParameters) { const parameter = markdownParameters[index]; if (parameter?.name === parameterName) { return parameter; @@ -110,7 +110,7 @@ function findParameter(parameterName, index, markdownParameters) { * @param {string[]} declaredParameters * @param {Array} markdownParameters */ -function parseParameters(declaredParameters, markdownParameters) { +export function parseParameters(declaredParameters, markdownParameters) { /** * @type {Array} */ diff --git a/src/generators/llms-txt/utils/__tests__/buildApiDocLink.test.mjs b/src/generators/llms-txt/utils/__tests__/buildApiDocLink.test.mjs new file mode 100644 index 00000000..d9ef8737 --- /dev/null +++ b/src/generators/llms-txt/utils/__tests__/buildApiDocLink.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { getEntryDescription, buildApiDocLink } from '../buildApiDocLink.mjs'; + +describe('getEntryDescription', () => { + it('returns llm_description when available', () => { + const entry = { + llm_description: 'LLM generated description', + content: { children: [] }, + }; + + const result = getEntryDescription(entry); + assert.equal(result, 'LLM generated description'); + }); + + it('extracts first paragraph when no llm_description', () => { + const entry = { + content: { + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'First paragraph' }], + }, + ], + }, + }; + + const result = getEntryDescription(entry); + assert.ok(result.length > 0); + }); + + it('returns empty string when no paragraph found', () => { + const entry = { + content: { + children: [ + { type: 'heading', children: [{ type: 'text', value: 'Title' }] }, + ], + }, + }; + + const result = getEntryDescription(entry); + assert.equal(result, ''); + }); + + it('removes newlines from description', () => { + const entry = { + content: { + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'Line 1\nLine 2\r\nLine 3' }], + }, + ], + }, + }; + + const result = getEntryDescription(entry); + assert.equal(result.includes('\n'), false); + assert.equal(result.includes('\r'), false); + }); +}); + +describe('buildApiDocLink', () => { + it('builds markdown link with description', () => { + const entry = { + heading: { data: { name: 'Test API' } }, + api_doc_source: 'doc/api/test.md', + llm_description: 'Test description', + }; + + const result = buildApiDocLink(entry); + assert.ok(result.includes('[Test API]')); + assert.ok(result.includes('/docs/latest/api/test.md')); + assert.ok(result.includes('Test description')); + }); + + it('handles doc path replacement', () => { + const entry = { + heading: { data: { name: 'API Method' } }, + api_doc_source: 'doc/some/path.md', + content: { children: [] }, + }; + + const result = buildApiDocLink(entry); + assert.ok(result.includes('/docs/latest/some/path.md')); + }); +}); diff --git a/src/generators/llms-txt/utils/buildApiDocLink.mjs b/src/generators/llms-txt/utils/buildApiDocLink.mjs index 0752fcbe..33b6a0c1 100644 --- a/src/generators/llms-txt/utils/buildApiDocLink.mjs +++ b/src/generators/llms-txt/utils/buildApiDocLink.mjs @@ -9,7 +9,7 @@ import { transformNodeToString } from '../../../utils/unist.mjs'; * @param {ApiDocMetadataEntry} entry * @returns {string} */ -const getEntryDescription = entry => { +export const getEntryDescription = entry => { if (entry.llm_description) { return entry.llm_description; } diff --git a/src/utils/__tests__/generators.test.mjs b/src/utils/__tests__/generators.test.mjs new file mode 100644 index 00000000..e904874a --- /dev/null +++ b/src/utils/__tests__/generators.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + groupNodesByModule, + getVersionFromSemVer, + coerceSemVer, + getCompatibleVersions, + sortChanges, +} from '../generators.mjs'; + +describe('groupNodesByModule', () => { + it('groups nodes by api property', () => { + const nodes = [ + { api: 'fs', name: 'readFile' }, + { api: 'http', name: 'createServer' }, + { api: 'fs', name: 'writeFile' }, + ]; + + const result = groupNodesByModule(nodes); + assert.equal(result.get('fs').length, 2); + assert.equal(result.get('http').length, 1); + }); + + it('handles empty array', () => { + const result = groupNodesByModule([]); + assert.equal(result.size, 0); + }); +}); + +describe('getVersionFromSemVer', () => { + it('returns major.x for minor 0', () => { + const version = { major: 18, minor: 0, patch: 0 }; + const result = getVersionFromSemVer(version); + assert.equal(result, '18.x'); + }); + + it('returns major.minor.x for non-zero minor', () => { + const version = { major: 18, minor: 5, patch: 2 }; + const result = getVersionFromSemVer(version); + assert.equal(result, '18.5.x'); + }); +}); + +describe('coerceSemVer', () => { + it('returns valid semver unchanged', () => { + const result = coerceSemVer('1.2.3'); + assert.equal(result.version, '1.2.3'); + }); + + it('coerces invalid version to fallback', () => { + const result = coerceSemVer('invalid'); + assert.equal(result.version, '0.0.0'); + }); + + it('handles null input', () => { + const result = coerceSemVer(null); + assert.equal(result.version, '0.0.0'); + }); +}); + +describe('getCompatibleVersions', () => { + it('filters releases by major version', () => { + const releases = [ + { version: { major: 16 } }, + { version: { major: 18 } }, + { version: { major: 20 } }, + ]; + + const result = getCompatibleVersions('18.0.0', releases); + assert.equal(result.length, 2); + assert.equal(result[0].version.major, 18); + assert.equal(result[1].version.major, 20); + }); + + it('includes all releases when introduced version is old', () => { + const releases = [{ version: { major: 16 } }, { version: { major: 18 } }]; + + const result = getCompatibleVersions('14.0.0', releases); + assert.equal(result.length, 2); + }); +}); + +describe('sortChanges', () => { + it('sorts changes by version', () => { + const changes = [ + { version: '18.5.0' }, + { version: '16.2.0' }, + { version: '20.1.0' }, + ]; + + const result = sortChanges(changes); + assert.equal(result[0].version, '16.2.0'); + assert.equal(result[1].version, '18.5.0'); + assert.equal(result[2].version, '20.1.0'); + }); + + it('handles array versions', () => { + const changes = [ + { version: ['18.5.0', '18.4.0'] }, + { version: ['16.2.0'] }, + ]; + + const result = sortChanges(changes); + assert.equal(result[0].version[0], '16.2.0'); + assert.equal(result[1].version[0], '18.5.0'); + }); + + it('sorts by custom key', () => { + const changes = [{ customVersion: '18.0.0' }, { customVersion: '16.0.0' }]; + + const result = sortChanges(changes, 'customVersion'); + assert.equal(result[0].customVersion, '16.0.0'); + assert.equal(result[1].customVersion, '18.0.0'); + }); +});