From 0ff5ddc410a889f27e0f48fd4b5d45e7e5173194 Mon Sep 17 00:00:00 2001 From: Revone Date: Tue, 19 Nov 2024 23:20:25 +1300 Subject: [PATCH] chore: Implement unit tests as example code in the README.md and source code comments. --- CHANGELOG.md | 2 +- README.md | 6 + package-lock.json | 52 ++--- package.json | 10 +- test/unit/data-structures/heap/heap.test.ts | 175 +++++++++++++++++ test/utils/string.ts | 55 +++++- testToExample.ts | 207 ++++++++++++++++++++ 7 files changed, 474 insertions(+), 33 deletions(-) create mode 100644 testToExample.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 470b4d4..90663e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to this project will be documented in this file. - [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - [`auto-changelog`](https://github.com/CookPete/auto-changelog) -## [v1.53.1](https://github.com/zrwusa/data-structure-typed/compare/v1.51.5...main) (upcoming) +## [v1.53.2](https://github.com/zrwusa/data-structure-typed/compare/v1.51.5...main) (upcoming) ### Changes diff --git a/README.md b/README.md index c44e802..2e8d7dc 100644 --- a/README.md +++ b/README.md @@ -798,6 +798,12 @@ Array.from(dijkstraResult?.seen ?? []).map(vertex => vertex.key) // ['A', 'B', ' ``` + +[//]: # (No deletion!!! Start of Example Replace Section) + + +[//]: # (No deletion!!! End of Example Replace Section) + ## API docs & Examples [API Docs](https://data-structure-typed-docs.vercel.app) diff --git a/package-lock.json b/package-lock.json index eae3ca6..83e6ec9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "data-structure-typed", - "version": "1.53.1", + "version": "1.53.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "data-structure-typed", - "version": "1.53.1", + "version": "1.53.2", "license": "MIT", "devDependencies": { "@eslint/compat": "^1.2.2", @@ -19,11 +19,11 @@ "@typescript-eslint/eslint-plugin": "^8.12.1", "@typescript-eslint/parser": "^8.12.1", "auto-changelog": "^2.5.0", - "avl-tree-typed": "^1.53.0", + "avl-tree-typed": "^1.53.1", "benchmark": "^2.1.4", - "binary-tree-typed": "^1.53.0", - "bst-typed": "^1.53.0", - "data-structure-typed": "^1.53.0", + "binary-tree-typed": "^1.53.1", + "bst-typed": "^1.53.1", + "data-structure-typed": "^1.53.1", "dependency-cruiser": "^16.5.0", "doctoc": "^2.2.1", "eslint": "^9.13.0", @@ -32,7 +32,7 @@ "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-import": "^2.31.0", "fast-glob": "^3.3.2", - "heap-typed": "^1.53.0", + "heap-typed": "^1.53.1", "istanbul-badges-readme": "^1.9.0", "jest": "^29.7.0", "js-sdsl": "^4.4.2", @@ -3437,13 +3437,13 @@ } }, "node_modules/avl-tree-typed": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/avl-tree-typed/-/avl-tree-typed-1.53.0.tgz", - "integrity": "sha512-SVk6zEvzrB2GhHVFOhVO2yaB7p9iLGy2aml0j7UW1rWp+VACw4SZQhKuldl2qC7ECb9AOIbajD7Y9ott8Qfy8Q==", + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/avl-tree-typed/-/avl-tree-typed-1.53.1.tgz", + "integrity": "sha512-4k54zM7lNtz37FSgNstoGlRSqN9kO1y98Lz7+8RXAoOtYe1McIUyJYhuukOFJi6XshEQDFHaxz/zklZJFqLq/g==", "dev": true, "license": "MIT", "dependencies": { - "data-structure-typed": "^1.53.0" + "data-structure-typed": "^1.53.1" } }, "node_modules/babel-jest": { @@ -3602,13 +3602,13 @@ } }, "node_modules/binary-tree-typed": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/binary-tree-typed/-/binary-tree-typed-1.53.0.tgz", - "integrity": "sha512-5EmlwbR4Kaphual+wF2rJwpZFhhK2u6zOFiA+yagWDbH6tzBCojT5o2DN+1tmW1Pj6bGJxz7PHcDBIdBkQruMw==", + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/binary-tree-typed/-/binary-tree-typed-1.53.1.tgz", + "integrity": "sha512-Ym/VfNG8iRhBUxxlCFArQSbf46m39voLVXdKbLzP/G3Sb3N7xwSwMTRC2Evx9lEWnhMmg4WHXsqZByun2xIljw==", "dev": true, "license": "MIT", "dependencies": { - "data-structure-typed": "^1.53.0" + "data-structure-typed": "^1.53.1" } }, "node_modules/brace-expansion": { @@ -3691,13 +3691,13 @@ } }, "node_modules/bst-typed": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/bst-typed/-/bst-typed-1.53.0.tgz", - "integrity": "sha512-f1f+RlbIA6J2reFO/DwLRLaZVBmMNNyKCi5eWd+r5ojV9bDC/IZ34nzID0zSJUwVqtAtIpaK9x63XAo42+5SoQ==", + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/bst-typed/-/bst-typed-1.53.1.tgz", + "integrity": "sha512-pSAIStgqneF4kTbbJJU7tcTMUBvchUXoID9fpKmcpsLHzG0jgfmMnGYZBZlRQXYJkUAsiFHeDaDMbK6W2muBQA==", "dev": true, "license": "MIT", "dependencies": { - "data-structure-typed": "^1.53.0" + "data-structure-typed": "^1.53.1" } }, "node_modules/buffer-from": { @@ -4069,9 +4069,9 @@ } }, "node_modules/data-structure-typed": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/data-structure-typed/-/data-structure-typed-1.53.0.tgz", - "integrity": "sha512-bukacSwbWyypNTdQCt51rwNcqM1hacHG6mMJ2RDBqsz8jJ5N4oMOgQhDyA1GkvMvdq83mcj9ilCFZ6lDcwShqA==", + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/data-structure-typed/-/data-structure-typed-1.53.1.tgz", + "integrity": "sha512-D3KDDnf6zY7vW45ht/U467UCVx1Zs6E3uVlGe4HSa+qvMHtPZMZahU5X8HbQVfzHZ1wg7NBKW6OtQdy0YzbuSQ==", "dev": true, "license": "MIT" }, @@ -5946,13 +5946,13 @@ } }, "node_modules/heap-typed": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/heap-typed/-/heap-typed-1.53.0.tgz", - "integrity": "sha512-MtbVdNGb2mvLrv67j0ajaMDr/u0HpR0tMaCj1MUrmXKK+IhVqniq7I8ADUlDrEPpKXwES8RBa0v7lA5umKR9hg==", + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/heap-typed/-/heap-typed-1.53.1.tgz", + "integrity": "sha512-4/fHFXV3YLC0wvi+H3IXTRF4t2CzU1AcaB8PZvU/vR5V7lTPHNAb9IUGj9bC1f/7tmz/3a38r1Mx0xJVPIhzaw==", "dev": true, "license": "MIT", "dependencies": { - "data-structure-typed": "^1.53.0" + "data-structure-typed": "^1.53.1" } }, "node_modules/html-escaper": { diff --git a/package.json b/package.json index e2351df..6c0f2c5 100644 --- a/package.json +++ b/package.json @@ -69,11 +69,11 @@ "@typescript-eslint/eslint-plugin": "^8.12.1", "@typescript-eslint/parser": "^8.12.1", "auto-changelog": "^2.5.0", - "avl-tree-typed": "^1.53.0", + "avl-tree-typed": "^1.53.1", "benchmark": "^2.1.4", - "binary-tree-typed": "^1.53.0", - "bst-typed": "^1.53.0", - "data-structure-typed": "^1.53.0", + "binary-tree-typed": "^1.53.1", + "bst-typed": "^1.53.1", + "data-structure-typed": "^1.53.1", "dependency-cruiser": "^16.5.0", "doctoc": "^2.2.1", "eslint": "^9.13.0", @@ -82,7 +82,7 @@ "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-import": "^2.31.0", "fast-glob": "^3.3.2", - "heap-typed": "^1.53.0", + "heap-typed": "^1.53.1", "istanbul-badges-readme": "^1.9.0", "jest": "^29.7.0", "js-sdsl": "^4.4.2", diff --git a/test/unit/data-structures/heap/heap.test.ts b/test/unit/data-structures/heap/heap.test.ts index 3a0a116..b82ae91 100644 --- a/test/unit/data-structures/heap/heap.test.ts +++ b/test/unit/data-structures/heap/heap.test.ts @@ -457,3 +457,178 @@ describe('FibonacciHeap Stress Test', () => { ); }); }); + +describe('classic use', () => { + it('heap sort', () => { + function heapSort(arr: number[]): number[] { + const heap = new Heap(arr, { comparator: (a, b) => a - b }); + const sorted: number[] = []; + while (!heap.isEmpty()) { + sorted.push(heap.poll()!); // Poll minimum element + } + return sorted; + } + + const array = [5, 3, 8, 4, 1, 2]; + expect(heapSort(array)).toEqual([1, 2, 3, 4, 5, 8]); + }); + + it('top k', () => { + function topKElements(arr: number[], k: number): number[] { + const heap = new Heap([], { comparator: (a, b) => b - a }); // Max heap + arr.forEach(num => { + heap.add(num); + if (heap.size > k) heap.poll(); // Keep the heap size at K + }); + return heap.toArray(); + } + + const numbers = [10, 30, 20, 5, 15, 25]; + console.log('Top K:', topKElements(numbers, 3)); // [15, 10, 5] + expect(topKElements(numbers, 3)).toEqual([15, 10, 5]); + }); + + it('merge sorted sequences', () => { + function mergeSortedSequences(sequences: number[][]): number[] { + const heap = new Heap<{ value: number; seqIndex: number; itemIndex: number }>([], { + comparator: (a, b) => a.value - b.value // Min heap + }); + + // Initialize heap + sequences.forEach((seq, seqIndex) => { + if (seq.length) { + heap.add({ value: seq[0], seqIndex, itemIndex: 0 }); + } + }); + + const merged: number[] = []; + while (!heap.isEmpty()) { + const { value, seqIndex, itemIndex } = heap.poll()!; + merged.push(value); + + if (itemIndex + 1 < sequences[seqIndex].length) { + heap.add({ + value: sequences[seqIndex][itemIndex + 1], + seqIndex, + itemIndex: itemIndex + 1 + }); + } + } + + return merged; + } + + const sequences = [ + [1, 4, 7], + [2, 5, 8], + [3, 6, 9] + ]; + console.log('Merged Sequences:', mergeSortedSequences(sequences)); // [1, 2, 3, 4, 5, 6, 7, 8, 9] + expect(mergeSortedSequences(sequences)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + it('@example median finder', () => { + class MedianFinder { + private low: MaxHeap; // Max heap, stores the smaller half + private high: MinHeap; // Min heap, stores the larger half + + constructor() { + this.low = new MaxHeap([]); + this.high = new MinHeap([]); + } + + addNum(num: number): void { + if (this.low.isEmpty() || num <= this.low.peek()!) this.low.add(num); + else this.high.add(num); + + // Balance heaps + if (this.low.size > this.high.size + 1) this.high.add(this.low.poll()!); + else if (this.high.size > this.low.size) this.low.add(this.high.poll()!); + } + + findMedian(): number { + return this.low.peek()!; + } + } + + const medianFinder = new MedianFinder(); + medianFinder.addNum(10); + expect(medianFinder.findMedian()).toBe(10); + medianFinder.addNum(20); + expect(medianFinder.findMedian()).toBe(10); + medianFinder.addNum(30); + expect(medianFinder.findMedian()).toBe(20); + medianFinder.addNum(40); + expect(medianFinder.findMedian()).toBe(20); + medianFinder.addNum(50); + expect(medianFinder.findMedian()).toBe(30); + }); + + it('schedule tasks', () => { + type Task = [string, number]; + + function scheduleTasks(tasks: Task[], machines: number): Map { + const machineHeap = new Heap<{ id: number; load: number }>([], { comparator: (a, b) => a.load - b.load }); // Min heap + const allocation = new Map(); + + // Initialize the load on each machine + for (let i = 0; i < machines; i++) { + machineHeap.add({ id: i, load: 0 }); + allocation.set(i, []); + } + + // Assign tasks + tasks.forEach(([task, load]) => { + const machine = machineHeap.poll()!; + allocation.get(machine.id)!.push([task, load]); + machine.load += load; + machineHeap.add(machine); // The machine after updating the load is re-entered into the heap + }); + + return allocation; + } + + const tasks: Task[] = [ + ['Task1', 3], + ['Task2', 1], + ['Task3', 2], + ['Task4', 5], + ['Task5', 4] + ]; + const expectedMap = new Map(); + expectedMap.set(0, [ + ['Task1', 3], + ['Task4', 5] + ]); + expectedMap.set(1, [ + ['Task2', 1], + ['Task3', 2], + ['Task5', 4] + ]); + expect(scheduleTasks(tasks, 2)).toEqual(expectedMap); + }); + + it('@example Use Heap for load balancing', () => { + function loadBalance(requests: number[], servers: number): number[] { + const serverHeap = new Heap<{ id: number; load: number }>([], { comparator: (a, b) => a.load - b.load }); // min heap + const serverLoads = new Array(servers).fill(0); + + for (let i = 0; i < servers; i++) { + serverHeap.add({ id: i, load: 0 }); + } + + requests.forEach(req => { + const server = serverHeap.poll()!; + serverLoads[server.id] += req; + server.load += req; + serverHeap.add(server); // The server after updating the load is re-entered into the heap + }); + + return serverLoads; + } + + const requests = [5, 2, 8, 3, 7]; + const serversLoads = loadBalance(requests, 3); + expect(serversLoads).toEqual([12, 8, 5]); + }); +}); diff --git a/test/utils/string.ts b/test/utils/string.ts index cb0ff5c..9000bc8 100644 --- a/test/utils/string.ts +++ b/test/utils/string.ts @@ -1 +1,54 @@ -export {}; +/** + * Convert any string to CamelCase format + */ +export function toCamelCase(str: string): string { + return str + .toLowerCase() + .replace(/[^a-zA-Z0-9]+(.)/g, (_, chr) => chr.toUpperCase()); +} + +/** + * Convert any string to SnakeCase format + */ +export function toSnakeCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, '$1_$2') // Add underline between lowercase and uppercase letters + .toLowerCase() // Convert to lowercase + .replace(/[^a-z0-9]+/g, '_'); // Replace non-alphanumeric characters with underscores +} + +/** + * Convert any string to PascalCase format (first letter capitalized) + */ +export function toPascalCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, '$1 $2') // Add space between lowercase and uppercase letters + .replace(/[^a-zA-Z0-9]+/g, ' ') // Replace non-alphanumeric characters with spaces + .split(' ') // Separate strings by spaces + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) // The first letter is capitalized, the rest are lowercase + .join(''); // Combine into a string +} + +/** + * Convert CamelCase or SnakeCase string to string format with specified separator + */ +export function toSeparatedCase(str: string, separator: string = '_'): string { + return str + .replace(/([a-z0-9])([A-Z])/g, '$1' + separator + '$2') + .replace(/[_\s]+/g, separator) + .toLowerCase(); +} + +/** + * Convert the string to all uppercase and delimit it using the specified delimiter + */ +export function toUpperSeparatedCase( + str: string, + separator: string = '_', +): string { + return str + .toUpperCase() // Convert all letters to uppercase + .replace(/([a-z0-9])([A-Z])/g, '$1' + separator + '$2') // Add separator between lowercase letters and uppercase letters + .replace(/[^A-Z0-9]+/g, separator) // Replace non-alphanumeric characters with separators + .replace(new RegExp(`^${separator}|${separator}$`, 'g'), ''); // Remove the starting and ending separators +} diff --git a/testToExample.ts b/testToExample.ts new file mode 100644 index 0000000..3b3727e --- /dev/null +++ b/testToExample.ts @@ -0,0 +1,207 @@ +import fs from 'fs'; +import path from 'path'; +import * as ts from 'typescript'; +import { toPascalCase } from './test/utils'; + +const isReplaceMD = false; +const START_MARKER = '[//]: # (No deletion!!! Start of Example Replace Section)'; +const END_MARKER = '[//]: # (No deletion!!! End of Example Replace Section)'; + +/** + * Recursively retrieve all `.ts` files in a directory. + */ +function getAllTestFiles(dir: string): string[] { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + const files = entries + .filter(file => !file.isDirectory() && file.name.endsWith('.ts')) + .map(file => path.join(dir, file.name)); + + const directories = entries.filter(entry => entry.isDirectory()); + + for (const directory of directories) { + files.push(...getAllTestFiles(path.join(dir, directory.name))); + } + + return files; +} + +/** + * Extract test cases with `@example` from TypeScript files using AST. + */ +function extractExamplesFromFile(filePath: string): { name: string; body: string }[] { + const fileContent = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile(filePath, fileContent, ts.ScriptTarget.Latest, true); + + const examples: { name: string; body: string }[] = []; + + function visit(node: ts.Node) { + if ( + ts.isCallExpression(node) && // Ensure it's a function call + node.arguments.length >= 2 && // At least two arguments + ts.isStringLiteral(node.arguments[0]) && // First argument is a string + node.arguments[0].text.startsWith('@example') && // Matches @example + ts.isArrowFunction(node.arguments[1]) // Second argument is an arrow function + ) { + const exampleName = node.arguments[0].text.replace('@example ', '').trim(); + const bodyNode = node.arguments[1].body; + + let exampleBody: string; + if (ts.isBlock(bodyNode)) { + // If it's a block, remove outer {} + exampleBody = bodyNode.statements + .map(stmt => stmt.getFullText(sourceFile)) + .join('') + .trim(); + } else { + // If it's a single expression, use it directly + exampleBody = bodyNode.getFullText(sourceFile).trim(); + } + + const transformedBody = exampleBody + .replace( + /expect\((.*?)\)\.(toEqual|toBe|toStrictEqual|toHaveLength|toMatchObject)\((.*?)\);/g, + (match, actual, method, expected) => { + return `console.log(${actual}); // ${expected.trim()}`; + } + ) + .trim(); + + examples.push({ name: exampleName, body: transformedBody }); + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + + return examples; +} + +/** + * Add examples to the corresponding class in the source file. + */ +function addExamplesToSourceFile( + sourceFilePath: string, + className: string, + examples: { name: string; body: string }[] +): void { + if (!fs.existsSync(sourceFilePath)) { + console.warn(`Source file not found: ${sourceFilePath}`); + return; + } + + const sourceContent = fs.readFileSync(sourceFilePath, 'utf-8'); + const sourceFile = ts.createSourceFile(sourceFilePath, sourceContent, ts.ScriptTarget.Latest, true); + + let updatedContent = sourceContent; + + const classNode = sourceFile.statements.find( + stmt => ts.isClassDeclaration(stmt) && stmt.name?.text === className + ) as ts.ClassDeclaration | undefined; + + if (classNode) { + const classStart = classNode.getStart(sourceFile); + const classEnd = classNode.getEnd(); + const classText = classNode.getFullText(sourceFile); + + // 提取注释内容 + const existingCommentMatch = classText.match(/\/\*\*([\s\S]*?)\*\//); + if (!existingCommentMatch) { + console.warn(`No existing comment found for class: ${className}`); + return; + } + + const existingCommentInner = existingCommentMatch[1]; // 提取注释内容(不包括`/**`和`*/`) + + // 替换 @example 部分 + const exampleSection = examples + .map( + example => + `@example \n * \/\/ ${example.name} \n${example.body + .split('\n') + .map(line => ` * ${line}`) + .join('\n')}\n * \n` + ) + .join('\n'); + + let newComment = ''; + if (existingCommentInner.includes('@example')) { + newComment = existingCommentInner.replace(/@example[\s\S]*?(?=\*\/|$)/g, exampleSection); + } else { + newComment = existingCommentInner + `\n * ${exampleSection}`; + } + + + // 替换原始内容 + updatedContent = + sourceContent.slice(0, classStart - existingCommentInner.length - 1) + + newComment + + classText.slice(existingCommentMatch[0].length).trim() + + sourceContent.slice(classEnd); + } + + fs.writeFileSync(sourceFilePath, updatedContent, 'utf-8'); + console.log(`Updated examples in ${sourceFilePath}`); +} + + +/** + * Process all test files and update README.md and source files. + */ +function updateExamples(testDir: string, readmePath: string, sourceBaseDir: string): void { + const testFiles = getAllTestFiles(testDir); + + let allExamples: string[] = []; + for (const file of testFiles) { + const examples = extractExamplesFromFile(file); + + if (examples.length === 0) { + console.log(`No @example found in test file: ${file}`); + continue; + } + + const relativePath = path.relative(testDir, file); + const sourceFilePath = path.resolve(sourceBaseDir, relativePath.replace('.test.ts', '.ts')); + const className = path.basename(sourceFilePath, '.ts'); + + addExamplesToSourceFile(sourceFilePath, toPascalCase(className), examples); + + allExamples = allExamples.concat( + examples.map(example => `### ${example.name}\n\`\`\`typescript\n${example.body}\n\`\`\``) + ); + } + + if (isReplaceMD && allExamples.length > 0) { + replaceExamplesInReadme(readmePath, allExamples); + } +} + +/** + * Replace content between markers in README.md. + */ +function replaceExamplesInReadme(readmePath: string, newExamples: string[]): void { + const readmeContent = fs.readFileSync(readmePath, 'utf-8'); + + const startIdx = readmeContent.indexOf(START_MARKER); + const endIdx = readmeContent.indexOf(END_MARKER); + + if (startIdx === -1 || endIdx === -1) { + throw new Error(`Markers not found in ${readmePath}`); + } + + const before = readmeContent.slice(0, startIdx + START_MARKER.length); + const after = readmeContent.slice(endIdx); + + const updatedContent = `${before}\n\n${newExamples.join('\n\n')}\n\n${after}`; + fs.writeFileSync(readmePath, updatedContent, 'utf-8'); + + console.log(`README.md updated with new examples.`); +} + +// Run the script +const testDir = path.resolve(__dirname, 'test/unit'); +const readmePath = path.resolve(__dirname, 'README.md'); +const sourceBaseDir = path.resolve(__dirname, 'src'); + +updateExamples(testDir, readmePath, sourceBaseDir);