From 860fe513c91721d805d7eaa0c9c0d915383b3fcc Mon Sep 17 00:00:00 2001 From: Many0nne Date: Tue, 3 Mar 2026 21:38:55 +0100 Subject: [PATCH 1/2] JSDoc Constraint Support for Mock Data Generation --- .claude/settings.local.json | 9 + .mock-config.json | 7 + README.md | 91 ++++++++- src/core/constrainedGenerator.ts | 180 +++++++++++++++++ src/core/parser.ts | 20 +- src/utils/constraintExtractor.ts | 254 ++++++++++++++++++++++++ src/utils/constraintValidator.ts | 100 ++++++++++ test-types/sample.ts | 10 + tests/core/constrainedGenerator.test.ts | 222 +++++++++++++++++++++ tests/utils/constraintExtractor.test.ts | 152 ++++++++++++++ tests/utils/constraintValidator.test.ts | 176 ++++++++++++++++ 11 files changed, 1218 insertions(+), 3 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .mock-config.json create mode 100644 src/core/constrainedGenerator.ts create mode 100644 src/utils/constraintExtractor.ts create mode 100644 src/utils/constraintValidator.ts create mode 100644 tests/core/constrainedGenerator.test.ts create mode 100644 tests/utils/constraintExtractor.test.ts create mode 100644 tests/utils/constraintValidator.test.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..3c52a00 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run test)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.mock-config.json b/.mock-config.json new file mode 100644 index 0000000..d306925 --- /dev/null +++ b/.mock-config.json @@ -0,0 +1,7 @@ +{ + "typesDir": "..\\testfront\\types", + "port": 8080, + "hotReload": true, + "cache": true, + "verbose": false +} \ No newline at end of file diff --git a/README.md b/README.md index 1bb42a9..f2a4e05 100644 --- a/README.md +++ b/README.md @@ -113,12 +113,93 @@ All endpoints are documented with examples and you can test them directly from y --- +## 🎯 Field Constraints with JSDoc Annotations + +Add validation constraints to your interfaces using JSDoc annotations. This ensures generated mock data follows your API rules. + +### Supported Constraints + +| Annotation | Type | Example | Description | +|---|---|---|---| +| `@minLength` | string | `@minLength 3` | Minimum string length | +| `@maxLength` | string | `@maxLength 10` | Maximum string length | +| `@pattern` | string | `@pattern ^[a-z]+$` | Regex pattern validation | +| `@min` | number | `@min 1` | Minimum numeric value | +| `@max` | number | `@max 100` | Maximum numeric value | +| `@enum` | any | `@enum ACTIVE,INACTIVE,PENDING` | Allowed values (comma-separated) | + +### Usage Examples + +```typescript +// @endpoint +export interface Badge { + /** @maxLength 10 */ + label: string; + + /** @min 1 @max 5 */ + level: number; + + /** @enum ACTIVE,INACTIVE,PENDING */ + status: string; +} +``` + +Response: +```json +{ "label": "New", "level": 3, "status": "ACTIVE" } +``` + +### More Examples + +```typescript +// @endpoint +export interface User { + id: number; + + /** @minLength 3 @maxLength 20 */ + username: string; + + /** @pattern ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ */ + email: string; + + /** @min 18 @max 120 */ + age: number; +} + +// @endpoint +export interface Product { + /** @maxLength 50 */ + title: string; + + /** @minLength 10 @maxLength 500 */ + description: string; + + /** @min 0.01 @max 999999.99 */ + price: number; + + /** @enum DRAFT,PUBLISHED,ARCHIVED */ + status: string; +} +``` + +### How It Works + +1. Constraints are extracted from JSDoc comments when generating mock data +2. Intermock generates base mock data +3. The constraint resolver applies your rules to ensure valid data +4. **No validation** - constraints are applied, not enforced. Mocks always return valid data. + + + ## Available Commands - `npm run dev` - Start development server - `npm run build` - Compile TypeScript - `npm start` - Start production server -- `npm test` - Run tests +- `npm test` - Run all tests +- `npm test -- constraintExtractor.test.ts` - Test constraint JSDoc extraction +- `npm test -- constraintValidator.test.ts` - Test constraint validation +- `npm test -- constrainedGenerator.test.ts` - Test constrained data generation --- @@ -129,7 +210,13 @@ The server maps URL paths to TypeScript interfaces by converting the route to Pa - `/user` → looks for `User` interface → returns single object - `/users` → looks for `User` interface → returns array of 3-10 objects -Only interfaces marked with `// @endpoint` are exposed. The server uses Intermock to parse TypeScript AST and Faker to generate realistic test data. Schemas are cached in memory for performance. +Only interfaces marked with `// @endpoint` are exposed. The server uses Intermock to parse TypeScript AST and Faker to generate realistic test data. + +**Constraint Processing**: When a request is made, the system: +1. Extracts JSDoc annotations from each field in the interface +2. Generates initial mock data using Intermock/Faker +3. Applies constraints (length, range, enum, pattern) to ensure valid test data +4. Caches schemas in memory for performance --- diff --git a/src/core/constrainedGenerator.ts b/src/core/constrainedGenerator.ts new file mode 100644 index 0000000..ef29537 --- /dev/null +++ b/src/core/constrainedGenerator.ts @@ -0,0 +1,180 @@ +import { faker } from '@faker-js/faker'; +import { FieldConstraint, FieldConstraints } from '../utils/constraintExtractor'; +import { + getStringLengthBounds, + getNumberBounds, + getEnumValues, + getPattern, +} from '../utils/constraintValidator'; + +/** + * Generates a constrained string value + */ +export function generateConstrainedString(constraints: FieldConstraint[]): string { + const enumValues = getEnumValues(constraints); + if (enumValues && enumValues.length > 0) { + return faker.helpers.arrayElement(enumValues); + } + + const pattern = getPattern(constraints); + if (pattern) { + // For patterns, try to generate matching string + return generateStringMatchingPattern(pattern); + } + + const { min, max } = getStringLengthBounds(constraints); + + // Generate a random string of appropriate length + const length = faker.number.int({ min, max }); + return faker.string.alphanumeric(length); +} + +/** + * Generates a constrained number value + */ +export function generateConstrainedNumber(constraints: FieldConstraint[]): number { + const enumValues = getEnumValues(constraints); + if (enumValues && enumValues.length > 0) { + return parseInt(faker.helpers.arrayElement(enumValues), 10); + } + + const { min, max } = getNumberBounds(constraints); + return faker.number.int({ min, max }); +} + +/** + * Generates a string that matches a regex pattern + */ +function generateStringMatchingPattern(pattern: RegExp): string { + // This is a simplified approach - for complex patterns, we generate a fallback + try { + const source = pattern.source; + + // Handle common patterns + if (source.includes('[a-z]') || source === '[a-z]*') { + return faker.string.alpha({ length: 10 }); + } + if (source.includes('[0-9]') || source === '[0-9]*') { + return faker.string.numeric({ length: 10 }); + } + if (source.includes('[a-zA-Z0-9]')) { + return faker.string.alphanumeric({ length: 10 }); + } + + // For email-like patterns + if (source.includes('@') || source === '^[^@]+@[^@]+\\.[^@]+$') { + return faker.internet.email(); + } + + // For URL-like patterns + if (source.includes('http') || source.includes('://')) { + return faker.internet.url(); + } + + // Fallback: generate alphanumeric string + return faker.string.alphanumeric(10); + } catch (error) { + // Fallback for complex patterns + return faker.string.alphanumeric(10); + } +} + +/** + * Applies constraints to generated mock data + * This function takes intermock-generated data and applies custom constraints + */ +export function applyConstraintsToMock( + mockData: Record, + fieldConstraints: FieldConstraints, + _knownTypes: Record = {} +): Record { + const constrained = { ...mockData }; + + for (const [fieldName, constraints] of Object.entries(fieldConstraints)) { + if (fieldName in constrained) { + const currentValue = constrained[fieldName]; + const fieldConstraintsList = constraints as FieldConstraint[]; + + // Determine the type of constraint to apply + if (fieldConstraintsList.some((c) => c.type.includes('Length') || c.type === 'pattern')) { + // String-like constraint + constrained[fieldName] = generateConstrainedString(fieldConstraintsList); + } else if (fieldConstraintsList.some((c) => c.type === 'min' || c.type === 'max')) { + // Number-like constraint + if (typeof currentValue !== 'number') { + constrained[fieldName] = generateConstrainedNumber(fieldConstraintsList); + } else { + // Validate and regenerate if needed + const value = currentValue as number; + const minConstraint = fieldConstraintsList.find((c) => c.type === 'min'); + const maxConstraint = fieldConstraintsList.find((c) => c.type === 'max'); + + const min = minConstraint ? (minConstraint.value as number) : value; + const max = maxConstraint ? (maxConstraint.value as number) : value; + + if (value < min || value > max) { + constrained[fieldName] = generateConstrainedNumber(fieldConstraintsList); + } + } + } else if (fieldConstraintsList.some((c) => c.type === 'enum')) { + // Enum constraint + const enumValues = getEnumValues(fieldConstraintsList); + if (enumValues && enumValues.length > 0) { + constrained[fieldName] = faker.helpers.arrayElement(enumValues); + } + } + } + } + + return constrained; +} + +/** + * Generates a value for a field based on its type and constraints + */ +export function generateFieldValue( + _fieldName: string, + fieldType: string, + constraints: FieldConstraint[] = [] +): unknown { + // If we have constraints that hint at the type + const hasStringConstraints = constraints.some((c) => + ['minLength', 'maxLength', 'pattern'].includes(c.type) + ); + const hasNumberConstraints = constraints.some((c) => + ['min', 'max'].includes(c.type) + ); + const hasEnumConstraints = constraints.some((c) => c.type === 'enum'); + + if (hasEnumConstraints) { + const enumValues = getEnumValues(constraints); + if (enumValues && enumValues.length > 0) { + // Try to convert to appropriate type + const value = faker.helpers.arrayElement(enumValues); + if (fieldType === 'number') { + return parseInt(value, 10); + } + return value; + } + } + + // Generate based on constraints or field type + if (hasStringConstraints || fieldType === 'string') { + return generateConstrainedString(constraints); + } + + if (hasNumberConstraints || ['number', 'int', 'integer'].includes(fieldType)) { + return generateConstrainedNumber(constraints); + } + + if (fieldType === 'boolean') { + return faker.datatype.boolean(); + } + + if (fieldType === 'date' || fieldType === 'Date') { + return faker.date.recent(); + } + + // Default fallback + return generateConstrainedString(constraints); +} diff --git a/src/core/parser.ts b/src/core/parser.ts index ab70e47..36ae4bd 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -1,6 +1,8 @@ import * as intermock from 'intermock'; import * as fs from 'fs'; import { MockGenerationOptions } from '../types/config'; +import { extractConstraints } from '../utils/constraintExtractor'; +import { applyConstraintsToMock } from './constrainedGenerator'; /** * Generates mock data from a TypeScript interface @@ -29,12 +31,28 @@ export function generateMockFromInterface( }); // Intermock returns an object with the interface name as key - const mockData = output[interfaceName as keyof typeof output]; + let mockData = output[interfaceName as keyof typeof output]; if (!mockData) { throw new Error(`Interface "${interfaceName}" not found in file ${filePath}`); } + // Extract and apply JSDoc constraints from the interface + try { + const constraints = extractConstraints(filePath, interfaceName); + if (Object.keys(constraints).length > 0) { + mockData = applyConstraintsToMock( + mockData as Record, + constraints + ); + } + } catch (constraintError) { + // Log constraint extraction errors but don't fail the entire generation + console.warn( + `Warning: Failed to extract constraints for ${interfaceName}: ${constraintError instanceof Error ? constraintError.message : String(constraintError)}` + ); + } + return mockData as Record; } catch (error) { throw new Error( diff --git a/src/utils/constraintExtractor.ts b/src/utils/constraintExtractor.ts new file mode 100644 index 0000000..6a52de0 --- /dev/null +++ b/src/utils/constraintExtractor.ts @@ -0,0 +1,254 @@ +import * as fs from 'fs'; +import * as ts from 'typescript'; + +/** + * Represents a single constraint on a field + */ +export interface FieldConstraint { + type: 'minLength' | 'maxLength' | 'pattern' | 'min' | 'max' | 'enum' | 'custom'; + value: string | number | string[]; +} + +/** + * Represents all constraints for a single field + */ +export interface FieldConstraints { + [fieldName: string]: FieldConstraint[]; +} + +/** + * Extracts JSDoc annotations from a TypeScript file and returns constraints + * + * Supported annotations: + * - @minLength number + * - @maxLength number + * - @pattern regex_string + * - @min number + * - @max number + * - @enum value1,value2,value3 + */ +export function extractConstraints( + filePath: string, + interfaceName: string +): FieldConstraints { + const fileContent = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile( + filePath, + fileContent, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + + const constraints: FieldConstraints = {}; + + // Visit all nodes in the AST + visit(sourceFile, interfaceName, constraints, sourceFile); + + return constraints; +} + +/** + * Recursively visits the AST to find the interface and extract constraints + */ +function visit( + node: ts.Node, + interfaceName: string, + constraints: FieldConstraints, + sourceFile?: ts.SourceFile +): void { + // Look for interface declarations + if (ts.isInterfaceDeclaration(node) && node.name?.text === interfaceName) { + // Process each property of the interface + node.members.forEach((member) => { + if (ts.isPropertySignature(member) && member.name) { + const propName = member.name.getText(); + const fieldConstraints = extractJSDocConstraints(member, sourceFile); + + if (fieldConstraints.length > 0) { + constraints[propName] = fieldConstraints; + } + } + }); + return; + } + + // Recursively visit children + ts.forEachChild(node, (child) => visit(child, interfaceName, constraints, sourceFile)); +} + +/** + * Extracts JSDoc constraints from a property + */ +function extractJSDocConstraints(node: ts.PropertySignature, sourceFile?: ts.SourceFile): FieldConstraint[] { + const constraints: FieldConstraint[] = []; + + const jsDocs = ts.getJSDocCommentsAndTags(node); + + jsDocs.forEach((doc) => { + // doc can be a JSDocComment or a JSDocTag + if (typeof doc !== 'object') return; + + if ('tags' in doc && Array.isArray(doc.tags)) { + // It's a JSDocComment + doc.tags.forEach((tag) => { + const constraint = parseJSDocTag(tag, sourceFile); + if (constraint) { + constraints.push(constraint); + } + }); + } else if ('tagName' in doc) { + // It's a JSDocTag directly + const constraint = parseJSDocTag(doc as any, sourceFile); + if (constraint) { + constraints.push(constraint); + } + } + }); + + return constraints; +} + +/** + * Parses a single JSDoc tag and returns the constraint + */ +function parseJSDocTag(tag: ts.JSDocTag, sourceFile?: ts.SourceFile): FieldConstraint | null { + const tagName = tag.tagName?.text || (tag as any).tagName?.escapedText; + let comment = (tag as any).comment; + + if (!tagName) return null; + + // For enum tags, always extract from source file because TypeScript parser + // treats the first enum value as a type annotation + if (tagName === 'enum' && sourceFile) { + const tagText = tag.getFullText(sourceFile); + // Match everything after @enum and optional whitespace, until we hit */ or @ or end + const match = tagText.match(/@enum\s+([^*@]+?)(?=\s*(?:\*\/|@|$))/); + if (match && match[1]) { + comment = match[1].trim(); + } + } else { + // Handle comment that might be an array of nodes or a string + if (Array.isArray(comment)) { + comment = comment.map((node: any) => node.text || node).join(''); + } else if (typeof comment === 'object' && comment && 'text' in comment) { + comment = (comment as any).text; + } + + // If comment is still empty or not a string, try to extract from source file + if (!comment || typeof comment !== 'string') { + if (sourceFile) { + // Get the text of the entire tag and extract the value part + const tagText = tag.getFullText(sourceFile); + // Match everything after @tagName and optional whitespace, until we hit */ or @ or end + const match = tagText.match(new RegExp(`@${tagName}\\s+([^\\*@]+?)(?=\\s*(?:\\*\\/|@|$))`)); + if (match && match[1]) { + comment = match[1].trim(); + } else { + comment = ''; + } + } else { + comment = ''; + } + } + } + + switch (tagName) { + case 'minLength': { + const value = extractNumericValue(comment); + if (value !== null) { + return { type: 'minLength', value }; + } + break; + } + case 'maxLength': { + const value = extractNumericValue(comment); + if (value !== null) { + return { type: 'maxLength', value }; + } + break; + } + case 'min': { + const value = extractNumericValue(comment); + if (value !== null) { + return { type: 'min', value }; + } + break; + } + case 'max': { + const value = extractNumericValue(comment); + if (value !== null) { + return { type: 'max', value }; + } + break; + } + case 'pattern': { + const value = extractStringValue(comment); + if (value) { + return { type: 'pattern', value }; + } + break; + } + case 'enum': { + const values = extractEnumValues(comment); + if (values.length > 0) { + return { type: 'enum', value: values }; + } + break; + } + } + + return null; +} + +/** + * Extracts a numeric value from a JSDoc comment + */ +function extractNumericValue(comment: string | undefined): number | null { + if (!comment) return null; + + const match = comment.match(/\d+/); + if (match) { + return parseInt(match[0], 10); + } + + return null; +} + +/** + * Extracts a string value from a JSDoc comment + */ +function extractStringValue(comment: string | undefined): string | null { + if (!comment) return null; + + // Try to extract quoted string + const match = comment.match(/['"`]([^'"`]+)['"`]/); + if (match && match[1]) { + return match[1]; + } + + // Or just trim the comment + const trimmed = comment.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +/** + * Extracts enum values from a JSDoc comment + */ +function extractEnumValues(comment: string | undefined): string[] { + if (!comment) return []; + + // Remove leading @enum tag if present + let cleanComment = comment.trim(); + if (cleanComment.startsWith('@enum')) { + cleanComment = cleanComment.substring(5).trim(); + } + + // Split by comma and trim each value + const result = cleanComment + .split(',') + .map((v) => v.trim()) + .filter((v) => v.length > 0); + + return result; +} diff --git a/src/utils/constraintValidator.ts b/src/utils/constraintValidator.ts new file mode 100644 index 0000000..5ef5fd9 --- /dev/null +++ b/src/utils/constraintValidator.ts @@ -0,0 +1,100 @@ +import { FieldConstraint } from './constraintExtractor'; + +/** + * Validates a value against a single constraint + */ +export function validateConstraint( + value: string | number, + constraint: FieldConstraint +): boolean { + switch (constraint.type) { + case 'minLength': + return typeof value === 'string' && value.length >= (constraint.value as number); + + case 'maxLength': + return typeof value === 'string' && value.length <= (constraint.value as number); + + case 'min': + return typeof value === 'number' && value >= (constraint.value as number); + + case 'max': + return typeof value === 'number' && value <= (constraint.value as number); + + case 'pattern': { + const pattern = new RegExp(constraint.value as string); + return typeof value === 'string' && pattern.test(value); + } + + case 'enum': + return (constraint.value as string[]).includes(String(value)); + + default: + return true; + } +} + +/** + * Validates a value against all constraints + */ +export function validateAllConstraints( + value: string | number, + constraints: FieldConstraint[] +): boolean { + return constraints.every((constraint) => validateConstraint(value, constraint)); +} + +/** + * Gets constraints of a specific type + */ +export function getConstraintByType( + constraints: FieldConstraint[], + type: FieldConstraint['type'] +): FieldConstraint | null { + return constraints.find((c) => c.type === type) || null; +} + +/** + * Helper to get min/max length constraints for strings + */ +export function getStringLengthBounds( + constraints: FieldConstraint[] +): { min: number; max: number } { + const minConstraint = getConstraintByType(constraints, 'minLength'); + const maxConstraint = getConstraintByType(constraints, 'maxLength'); + + return { + min: minConstraint ? (minConstraint.value as number) : 0, + max: maxConstraint ? (maxConstraint.value as number) : 100, + }; +} + +/** + * Helper to get min/max constraints for numbers + */ +export function getNumberBounds( + constraints: FieldConstraint[] +): { min: number; max: number } { + const minConstraint = getConstraintByType(constraints, 'min'); + const maxConstraint = getConstraintByType(constraints, 'max'); + + return { + min: minConstraint ? (minConstraint.value as number) : 0, + max: maxConstraint ? (maxConstraint.value as number) : 1000, + }; +} + +/** + * Get enum values if constraint exists + */ +export function getEnumValues(constraints: FieldConstraint[]): string[] | null { + const enumConstraint = getConstraintByType(constraints, 'enum'); + return enumConstraint ? (enumConstraint.value as string[]) : null; +} + +/** + * Get pattern if constraint exists + */ +export function getPattern(constraints: FieldConstraint[]): RegExp | null { + const patternConstraint = getConstraintByType(constraints, 'pattern'); + return patternConstraint ? new RegExp(patternConstraint.value as string) : null; +} diff --git a/test-types/sample.ts b/test-types/sample.ts index 3222b1b..6f5086a 100644 --- a/test-types/sample.ts +++ b/test-types/sample.ts @@ -11,3 +11,13 @@ export interface Product { title: string; price: number; } + +// @endpoint +export interface Badge { + /** @maxLength 10 */ + label: string; + /** @min 1 @max 5 */ + level: number; + /** @enum ACTIVE,INACTIVE,PENDING */ + status: string; +} diff --git a/tests/core/constrainedGenerator.test.ts b/tests/core/constrainedGenerator.test.ts new file mode 100644 index 0000000..21e93bb --- /dev/null +++ b/tests/core/constrainedGenerator.test.ts @@ -0,0 +1,222 @@ +import { + generateConstrainedString, + generateConstrainedNumber, + applyConstraintsToMock, + generateFieldValue, +} from '../../src/core/constrainedGenerator'; +import { FieldConstraint, FieldConstraints } from '../../src/utils/constraintExtractor'; + +describe('constrainedGenerator', () => { + describe('generateConstrainedString', () => { + it('should generate string within maxLength constraint', () => { + const constraints: FieldConstraint[] = [{ type: 'maxLength', value: 10 }]; + + for (let i = 0; i < 10; i++) { + const value = generateConstrainedString(constraints); + expect(typeof value).toBe('string'); + expect(value.length).toBeLessThanOrEqual(10); + } + }); + + it('should generate string within minLength and maxLength', () => { + const constraints: FieldConstraint[] = [ + { type: 'minLength', value: 5 }, + { type: 'maxLength', value: 15 }, + ]; + + for (let i = 0; i < 10; i++) { + const value = generateConstrainedString(constraints); + expect(value.length).toBeGreaterThanOrEqual(5); + expect(value.length).toBeLessThanOrEqual(15); + } + }); + + it('should generate value from enum', () => { + const constraints: FieldConstraint[] = [ + { type: 'enum', value: ['RED', 'GREEN', 'BLUE'] }, + ]; + + const value = generateConstrainedString(constraints); + expect(['RED', 'GREEN', 'BLUE']).toContain(value); + }); + + it('should generate string matching pattern', () => { + const constraints: FieldConstraint[] = [ + { type: 'pattern', value: '[a-z]*' }, + ]; + + const value = generateConstrainedString(constraints); + expect(typeof value).toBe('string'); + // Pattern-based generation may not perfectly match, but shouldn't error + expect(value.length).toBeGreaterThan(0); + }); + }); + + describe('generateConstrainedNumber', () => { + it('should generate number within min and max bounds', () => { + const constraints: FieldConstraint[] = [ + { type: 'min', value: 10 }, + { type: 'max', value: 20 }, + ]; + + for (let i = 0; i < 10; i++) { + const value = generateConstrainedNumber(constraints); + expect(typeof value).toBe('number'); + expect(value).toBeGreaterThanOrEqual(10); + expect(value).toBeLessThanOrEqual(20); + } + }); + + it('should generate value from enum numbers', () => { + const constraints: FieldConstraint[] = [ + { type: 'enum', value: ['1', '2', '3'] }, + ]; + + const value = generateConstrainedNumber(constraints); + expect([1, 2, 3]).toContain(value); + }); + + it('should handle only min constraint', () => { + const constraints: FieldConstraint[] = [{ type: 'min', value: 100 }]; + + const value = generateConstrainedNumber(constraints); + expect(value).toBeGreaterThanOrEqual(100); + }); + + it('should handle only max constraint', () => { + const constraints: FieldConstraint[] = [{ type: 'max', value: 50 }]; + + const value = generateConstrainedNumber(constraints); + expect(value).toBeLessThanOrEqual(50); + }); + }); + + describe('applyConstraintsToMock', () => { + it('should apply string constraints to mock data', () => { + const mockData = { + label: 'this is a very long label that exceeds the maximum length', + value: 123, + }; + + const constraints: FieldConstraints = { + label: [{ type: 'maxLength', value: 10 }], + }; + + const result = applyConstraintsToMock(mockData, constraints); + + expect(result.label).toBeDefined(); + expect(typeof result.label).toBe('string'); + expect((result.label as string).length).toBeLessThanOrEqual(10); + expect(result.value).toBe(123); // Unchanged + }); + + it('should apply number constraints to mock data', () => { + const mockData = { + id: 1, + rating: 500, + }; + + const constraints: FieldConstraints = { + rating: [ + { type: 'min', value: 1 }, + { type: 'max', value: 5 }, + ], + }; + + const result = applyConstraintsToMock(mockData, constraints); + + expect(result.rating).toBeDefined(); + expect(typeof result.rating).toBe('number'); + expect(result.rating).toBeGreaterThanOrEqual(1); + expect(result.rating).toBeLessThanOrEqual(5); + }); + + it('should apply enum constraints to mock data', () => { + const mockData = { + status: 'unknown', + }; + + const constraints: FieldConstraints = { + status: [{ type: 'enum', value: ['ACTIVE', 'INACTIVE', 'PENDING'] }], + }; + + const result = applyConstraintsToMock(mockData, constraints); + + expect(['ACTIVE', 'INACTIVE', 'PENDING']).toContain(result.status); + }); + + it('should ignore fields not in constraints', () => { + const mockData = { + name: 'John', + email: 'john@example.com', + }; + + const constraints: FieldConstraints = { + name: [{ type: 'maxLength', value: 5 }], + }; + + const result = applyConstraintsToMock(mockData, constraints); + + expect(result.email).toBe('john@example.com'); // Unchanged + }); + + it('should handle empty constraints', () => { + const mockData = { + id: 1, + name: 'test', + }; + + const result = applyConstraintsToMock(mockData, {}); + + expect(result).toEqual(mockData); + }); + }); + + describe('generateFieldValue', () => { + it('should generate string without constraints', () => { + const value = generateFieldValue('name', 'string'); + expect(typeof value).toBe('string'); + }); + + it('should generate number without constraints', () => { + const value = generateFieldValue('count', 'number'); + expect(typeof value).toBe('number'); + }); + + it('should generate boolean without constraints', () => { + const value = generateFieldValue('active', 'boolean'); + expect(typeof value).toBe('boolean'); + }); + + it('should apply string constraints to generated value', () => { + const constraints: FieldConstraint[] = [{ type: 'maxLength', value: 8 }]; + + for (let i = 0; i < 5; i++) { + const value = generateFieldValue('code', 'string', constraints); + expect((value as string).length).toBeLessThanOrEqual(8); + } + }); + + it('should apply number constraints to generated value', () => { + const constraints: FieldConstraint[] = [ + { type: 'min', value: 1 }, + { type: 'max', value: 10 }, + ]; + + for (let i = 0; i < 5; i++) { + const value = generateFieldValue('level', 'number', constraints); + expect(value).toBeGreaterThanOrEqual(1); + expect(value).toBeLessThanOrEqual(10); + } + }); + + it('should respect enum constraint', () => { + const constraints: FieldConstraint[] = [ + { type: 'enum', value: ['DRAFT', 'PUBLISHED'] }, + ]; + + const value = generateFieldValue('state', 'string', constraints); + expect(['DRAFT', 'PUBLISHED']).toContain(value); + }); + }); +}); diff --git a/tests/utils/constraintExtractor.test.ts b/tests/utils/constraintExtractor.test.ts new file mode 100644 index 0000000..ae9cf23 --- /dev/null +++ b/tests/utils/constraintExtractor.test.ts @@ -0,0 +1,152 @@ +import { extractConstraints, FieldConstraint } from '../../src/utils/constraintExtractor'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; + +describe('constraintExtractor', () => { + let tempDir: string; + let testFile: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'constraint-test-')); + testFile = path.join(tempDir, 'test.ts'); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true }); + }); + + it('should extract maxLength constraint from JSDoc', () => { + const content = ` + // @endpoint + export interface Badge { + /** @maxLength 10 */ + label: string; + } + `; + fs.writeFileSync(testFile, content); + + const constraints = extractConstraints(testFile, 'Badge'); + + expect(constraints.label).toBeDefined(); + expect(constraints.label).toHaveLength(1); + expect(constraints.label![0]!.type).toBe('maxLength'); + expect(constraints.label![0]!.value).toBe(10); + }); + + it('should extract minLength constraint from JSDoc', () => { + const content = ` + // @endpoint + export interface Product { + /** @minLength 5 */ + sku: string; + } + `; + fs.writeFileSync(testFile, content); + + const constraints = extractConstraints(testFile, 'Product'); + + expect(constraints.sku).toBeDefined(); + expect(constraints.sku![0]!.type).toBe('minLength'); + expect(constraints.sku![0]!.value).toBe(5); + }); + + it('should extract min and max constraints for numbers', () => { + const content = ` + // @endpoint + export interface Rating { + /** @min 1 @max 5 */ + stars: number; + } + `; + fs.writeFileSync(testFile, content); + + const constraints = extractConstraints(testFile, 'Rating'); + + expect(constraints.stars).toHaveLength(2); + const types = constraints.stars!.map((c: FieldConstraint) => c.type); + expect(types).toContain('min'); + expect(types).toContain('max'); + }); + + it('should extract enum constraint', () => { + const content = ` + // @endpoint + export interface Status { + /** @enum ACTIVE,INACTIVE,PENDING */ + state: string; + } + `; + fs.writeFileSync(testFile, content); + + const constraints = extractConstraints(testFile, 'Status'); + + expect(constraints.state).toBeDefined(); + expect(constraints.state![0]!.type).toBe('enum'); + expect(constraints.state![0]!.value).toEqual(['ACTIVE', 'INACTIVE', 'PENDING']); + }); + + it('should extract pattern constraint', () => { + const content = ` + // @endpoint + export interface Email { + /** @pattern ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$ */ + address: string; + } + `; + fs.writeFileSync(testFile, content); + + const constraints = extractConstraints(testFile, 'Email'); + + expect(constraints.address).toBeDefined(); + expect(constraints.address![0]!.type).toBe('pattern'); + expect(typeof constraints.address![0]!.value).toBe('string'); + }); + + it('should extract multiple constraints on one field', () => { + const content = ` + // @endpoint + export interface User { + /** @minLength 3 @maxLength 50 */ + username: string; + } + `; + fs.writeFileSync(testFile, content); + + const constraints = extractConstraints(testFile, 'User'); + + expect(constraints.username).toHaveLength(2); + const types = constraints.username!.map((c: FieldConstraint) => c.type); + expect(types).toContain('minLength'); + expect(types).toContain('maxLength'); + }); + + it('should return empty constraints for interface without JSDoc annotations', () => { + const content = ` + // @endpoint + export interface Simple { + id: number; + name: string; + } + `; + fs.writeFileSync(testFile, content); + + const constraints = extractConstraints(testFile, 'Simple'); + + expect(Object.keys(constraints)).toHaveLength(0); + }); + + it('should return empty object for non-existent interface', () => { + const content = ` + // @endpoint + export interface Existing { + id: number; + } + `; + fs.writeFileSync(testFile, content); + + const constraints = extractConstraints(testFile, 'NonExistent'); + + expect(Object.keys(constraints)).toHaveLength(0); + }); +}); diff --git a/tests/utils/constraintValidator.test.ts b/tests/utils/constraintValidator.test.ts new file mode 100644 index 0000000..5690755 --- /dev/null +++ b/tests/utils/constraintValidator.test.ts @@ -0,0 +1,176 @@ +import { + validateConstraint, + validateAllConstraints, + getStringLengthBounds, + getNumberBounds, + getEnumValues, + getPattern, +} from '../../src/utils/constraintValidator'; +import { FieldConstraint } from '../../src/utils/constraintExtractor'; + +describe('constraintValidator', () => { + describe('validateConstraint', () => { + it('should validate maxLength constraint', () => { + const constraint: FieldConstraint = { type: 'maxLength', value: 10 }; + + expect(validateConstraint('hello', constraint)).toBe(true); + expect(validateConstraint('hello world', constraint)).toBe(false); + expect(validateConstraint('1234567890', constraint)).toBe(true); + }); + + it('should validate minLength constraint', () => { + const constraint: FieldConstraint = { type: 'minLength', value: 3 }; + + expect(validateConstraint('ab', constraint)).toBe(false); + expect(validateConstraint('abc', constraint)).toBe(true); + expect(validateConstraint('abcdef', constraint)).toBe(true); + }); + + it('should validate min constraint for numbers', () => { + const constraint: FieldConstraint = { type: 'min', value: 5 }; + + expect(validateConstraint(3, constraint)).toBe(false); + expect(validateConstraint(5, constraint)).toBe(true); + expect(validateConstraint(10, constraint)).toBe(true); + }); + + it('should validate max constraint for numbers', () => { + const constraint: FieldConstraint = { type: 'max', value: 100 }; + + expect(validateConstraint(50, constraint)).toBe(true); + expect(validateConstraint(100, constraint)).toBe(true); + expect(validateConstraint(101, constraint)).toBe(false); + }); + + it('should validate pattern constraint', () => { + const constraint: FieldConstraint = { type: 'pattern', value: '^[a-z]+$' }; + + expect(validateConstraint('abc', constraint)).toBe(true); + expect(validateConstraint('ABC', constraint)).toBe(false); + expect(validateConstraint('abc123', constraint)).toBe(false); + }); + + it('should validate enum constraint', () => { + const constraint: FieldConstraint = { type: 'enum', value: ['RED', 'GREEN', 'BLUE'] }; + + expect(validateConstraint('RED', constraint)).toBe(true); + expect(validateConstraint('GREEN', constraint)).toBe(true); + expect(validateConstraint('YELLOW', constraint)).toBe(false); + expect(validateConstraint(123, constraint)).toBe(false); + }); + }); + + describe('validateAllConstraints', () => { + it('should validate all constraints pass', () => { + const constraints: FieldConstraint[] = [ + { type: 'minLength', value: 3 }, + { type: 'maxLength', value: 10 }, + ]; + + expect(validateAllConstraints('hello', constraints)).toBe(true); + expect(validateAllConstraints('ab', constraints)).toBe(false); + expect(validateAllConstraints('hello world', constraints)).toBe(false); + }); + + it('should return true for empty constraints array', () => { + expect(validateAllConstraints('anything', [])).toBe(true); + }); + + it('should validate mixed constraints', () => { + const constraints: FieldConstraint[] = [ + { type: 'pattern', value: '^[a-z0-9]+$' }, + { type: 'maxLength', value: 20 }, + ]; + + expect(validateAllConstraints('abc123', constraints)).toBe(true); + expect(validateAllConstraints('ABC123', constraints)).toBe(false); + expect(validateAllConstraints('a' + 'x'.repeat(20), constraints)).toBe(false); + }); + }); + + describe('getStringLengthBounds', () => { + it('should extract min and max length bounds', () => { + const constraints: FieldConstraint[] = [ + { type: 'minLength', value: 5 }, + { type: 'maxLength', value: 15 }, + ]; + + const bounds = getStringLengthBounds(constraints); + expect(bounds.min).toBe(5); + expect(bounds.max).toBe(15); + }); + + it('should return defaults when no constraints', () => { + const bounds = getStringLengthBounds([]); + expect(bounds.min).toBe(0); + expect(bounds.max).toBe(100); + }); + + it('should handle only minLength constraint', () => { + const constraints: FieldConstraint[] = [{ type: 'minLength', value: 10 }]; + const bounds = getStringLengthBounds(constraints); + expect(bounds.min).toBe(10); + expect(bounds.max).toBe(100); + }); + + it('should handle only maxLength constraint', () => { + const constraints: FieldConstraint[] = [{ type: 'maxLength', value: 50 }]; + const bounds = getStringLengthBounds(constraints); + expect(bounds.min).toBe(0); + expect(bounds.max).toBe(50); + }); + }); + + describe('getNumberBounds', () => { + it('should extract min and max bounds', () => { + const constraints: FieldConstraint[] = [ + { type: 'min', value: 1 }, + { type: 'max', value: 10 }, + ]; + + const bounds = getNumberBounds(constraints); + expect(bounds.min).toBe(1); + expect(bounds.max).toBe(10); + }); + + it('should return defaults when no constraints', () => { + const bounds = getNumberBounds([]); + expect(bounds.min).toBe(0); + expect(bounds.max).toBe(1000); + }); + }); + + describe('getEnumValues', () => { + it('should extract enum values', () => { + const constraints: FieldConstraint[] = [ + { type: 'enum', value: ['A', 'B', 'C'] }, + ]; + + const values = getEnumValues(constraints); + expect(values).toEqual(['A', 'B', 'C']); + }); + + it('should return null when no enum constraint', () => { + const values = getEnumValues([{ type: 'maxLength', value: 10 }]); + expect(values).toBeNull(); + }); + }); + + describe('getPattern', () => { + it('should extract pattern as regex', () => { + const constraints: FieldConstraint[] = [ + { type: 'pattern', value: '^[a-z]+$' }, + ]; + + const pattern = getPattern(constraints); + expect(pattern).not.toBeNull(); + expect(pattern?.test('abc')).toBe(true); + expect(pattern?.test('ABC')).toBe(false); + }); + + it('should return null when no pattern constraint', () => { + const pattern = getPattern([{ type: 'maxLength', value: 10 }]); + expect(pattern).toBeNull(); + }); + }); +}); From 03324998bbf147f8d68708eec4516c9ba8b9121b Mon Sep 17 00:00:00 2001 From: Many0nne Date: Tue, 3 Mar 2026 22:02:15 +0100 Subject: [PATCH 2/2] fix: address JSDoc constraint review findings --- src/core/constrainedGenerator.ts | 117 +++++++++++++++---------------- src/utils/constraintExtractor.ts | 105 +++++++++++---------------- tests/core/parser.test.ts | 42 +++++++++++ 3 files changed, 139 insertions(+), 125 deletions(-) diff --git a/src/core/constrainedGenerator.ts b/src/core/constrainedGenerator.ts index ef29537..faf1ce6 100644 --- a/src/core/constrainedGenerator.ts +++ b/src/core/constrainedGenerator.ts @@ -35,48 +35,44 @@ export function generateConstrainedString(constraints: FieldConstraint[]): strin export function generateConstrainedNumber(constraints: FieldConstraint[]): number { const enumValues = getEnumValues(constraints); if (enumValues && enumValues.length > 0) { - return parseInt(faker.helpers.arrayElement(enumValues), 10); + const numericValues = enumValues.map((v) => parseFloat(v)).filter((v) => !isNaN(v)); + if (numericValues.length > 0) { + return faker.helpers.arrayElement(numericValues); + } } const { min, max } = getNumberBounds(constraints); + // Use float generation when either bound is a decimal + if (min % 1 !== 0 || max % 1 !== 0) { + return faker.number.float({ min, max }); + } return faker.number.int({ min, max }); } /** - * Generates a string that matches a regex pattern + * Generates a string that matches a regex pattern. + * Uses heuristics for common patterns; falls back to alphanumeric for others. */ function generateStringMatchingPattern(pattern: RegExp): string { - // This is a simplified approach - for complex patterns, we generate a fallback - try { - const source = pattern.source; + const source = pattern.source; - // Handle common patterns - if (source.includes('[a-z]') || source === '[a-z]*') { - return faker.string.alpha({ length: 10 }); - } - if (source.includes('[0-9]') || source === '[0-9]*') { - return faker.string.numeric({ length: 10 }); - } - if (source.includes('[a-zA-Z0-9]')) { - return faker.string.alphanumeric({ length: 10 }); - } - - // For email-like patterns - if (source.includes('@') || source === '^[^@]+@[^@]+\\.[^@]+$') { - return faker.internet.email(); - } - - // For URL-like patterns - if (source.includes('http') || source.includes('://')) { - return faker.internet.url(); - } - - // Fallback: generate alphanumeric string - return faker.string.alphanumeric(10); - } catch (error) { - // Fallback for complex patterns - return faker.string.alphanumeric(10); + if (source.includes('[a-z]') || source === '[a-z]*') { + return faker.string.alpha({ length: 10 }); + } + if (source.includes('[0-9]') || source === '[0-9]*') { + return faker.string.numeric({ length: 10 }); + } + if (source.includes('[a-zA-Z0-9]')) { + return faker.string.alphanumeric({ length: 10 }); + } + if (source.includes('@') || source === '^[^@]+@[^@]+\\.[^@]+$') { + return faker.internet.email(); } + if (source.includes('http') || source.includes('://')) { + return faker.internet.url(); + } + + return faker.string.alphanumeric(10); } /** @@ -86,7 +82,7 @@ function generateStringMatchingPattern(pattern: RegExp): string { export function applyConstraintsToMock( mockData: Record, fieldConstraints: FieldConstraints, - _knownTypes: Record = {} + knownTypes: Record = {} ): Record { const constrained = { ...mockData }; @@ -95,33 +91,35 @@ export function applyConstraintsToMock( const currentValue = constrained[fieldName]; const fieldConstraintsList = constraints as FieldConstraint[]; - // Determine the type of constraint to apply - if (fieldConstraintsList.some((c) => c.type.includes('Length') || c.type === 'pattern')) { - // String-like constraint - constrained[fieldName] = generateConstrainedString(fieldConstraintsList); - } else if (fieldConstraintsList.some((c) => c.type === 'min' || c.type === 'max')) { - // Number-like constraint - if (typeof currentValue !== 'number') { - constrained[fieldName] = generateConstrainedNumber(fieldConstraintsList); - } else { - // Validate and regenerate if needed - const value = currentValue as number; - const minConstraint = fieldConstraintsList.find((c) => c.type === 'min'); - const maxConstraint = fieldConstraintsList.find((c) => c.type === 'max'); - - const min = minConstraint ? (minConstraint.value as number) : value; - const max = maxConstraint ? (maxConstraint.value as number) : value; + // Determine the field type from knownTypes or the actual runtime value + const actualType = knownTypes[fieldName] ?? typeof currentValue; + const isNumeric = actualType === 'number'; - if (value < min || value > max) { - constrained[fieldName] = generateConstrainedNumber(fieldConstraintsList); - } - } - } else if (fieldConstraintsList.some((c) => c.type === 'enum')) { - // Enum constraint + if (fieldConstraintsList.some((c) => c.type === 'enum')) { const enumValues = getEnumValues(fieldConstraintsList); if (enumValues && enumValues.length > 0) { - constrained[fieldName] = faker.helpers.arrayElement(enumValues); + if (isNumeric) { + const numericValues = enumValues.map((v) => parseFloat(v)).filter((v) => !isNaN(v)); + constrained[fieldName] = + numericValues.length > 0 + ? faker.helpers.arrayElement(numericValues) + : faker.helpers.arrayElement(enumValues); + } else { + constrained[fieldName] = faker.helpers.arrayElement(enumValues); + } + } + } else if (isNumeric) { + const value = currentValue as number; + const minConstraint = fieldConstraintsList.find((c) => c.type === 'min'); + const maxConstraint = fieldConstraintsList.find((c) => c.type === 'max'); + const min = minConstraint ? (minConstraint.value as number) : value; + const max = maxConstraint ? (maxConstraint.value as number) : value; + + if (value < min || value > max) { + constrained[fieldName] = generateConstrainedNumber(fieldConstraintsList); } + } else { + constrained[fieldName] = generateConstrainedString(fieldConstraintsList); } } } @@ -149,12 +147,13 @@ export function generateFieldValue( if (hasEnumConstraints) { const enumValues = getEnumValues(constraints); if (enumValues && enumValues.length > 0) { - // Try to convert to appropriate type - const value = faker.helpers.arrayElement(enumValues); if (fieldType === 'number') { - return parseInt(value, 10); + const numericValues = enumValues.map((v) => parseFloat(v)).filter((v) => !isNaN(v)); + if (numericValues.length > 0) { + return faker.helpers.arrayElement(numericValues); + } } - return value; + return faker.helpers.arrayElement(enumValues); } } diff --git a/src/utils/constraintExtractor.ts b/src/utils/constraintExtractor.ts index 6a52de0..94c8a43 100644 --- a/src/utils/constraintExtractor.ts +++ b/src/utils/constraintExtractor.ts @@ -62,7 +62,11 @@ function visit( // Process each property of the interface node.members.forEach((member) => { if (ts.isPropertySignature(member) && member.name) { - const propName = member.name.getText(); + // Use .text for identifiers/string literals to avoid wrapping quotes + const propName = + ts.isIdentifier(member.name) || ts.isStringLiteral(member.name) + ? member.name.text + : member.name.getText(); const fieldConstraints = extractJSDocConstraints(member, sourceFile); if (fieldConstraints.length > 0) { @@ -99,7 +103,7 @@ function extractJSDocConstraints(node: ts.PropertySignature, sourceFile?: ts.Sou }); } else if ('tagName' in doc) { // It's a JSDocTag directly - const constraint = parseJSDocTag(doc as any, sourceFile); + const constraint = parseJSDocTag(doc as ts.JSDocTag, sourceFile); if (constraint) { constraints.push(constraint); } @@ -113,87 +117,63 @@ function extractJSDocConstraints(node: ts.PropertySignature, sourceFile?: ts.Sou * Parses a single JSDoc tag and returns the constraint */ function parseJSDocTag(tag: ts.JSDocTag, sourceFile?: ts.SourceFile): FieldConstraint | null { - const tagName = tag.tagName?.text || (tag as any).tagName?.escapedText; - let comment = (tag as any).comment; - + const tagName = tag.tagName.text; if (!tagName) return null; - // For enum tags, always extract from source file because TypeScript parser - // treats the first enum value as a type annotation + // Resolve the comment to a plain string. + // For @enum, always read from source text because the TS parser treats the + // first enum value as a type annotation and truncates the rest. + let commentStr: string | undefined; if (tagName === 'enum' && sourceFile) { const tagText = tag.getFullText(sourceFile); - // Match everything after @enum and optional whitespace, until we hit */ or @ or end const match = tagText.match(/@enum\s+([^*@]+?)(?=\s*(?:\*\/|@|$))/); - if (match && match[1]) { - comment = match[1].trim(); - } + commentStr = match?.[1]?.trim(); } else { - // Handle comment that might be an array of nodes or a string - if (Array.isArray(comment)) { - comment = comment.map((node: any) => node.text || node).join(''); - } else if (typeof comment === 'object' && comment && 'text' in comment) { - comment = (comment as any).text; + const raw = tag.comment; + // Normalize NodeArray to a plain string + if (Array.isArray(raw)) { + commentStr = (raw as ts.NodeArray).map((node) => node.text).join(''); + } else if (typeof raw === 'string') { + commentStr = raw; } - // If comment is still empty or not a string, try to extract from source file - if (!comment || typeof comment !== 'string') { - if (sourceFile) { - // Get the text of the entire tag and extract the value part - const tagText = tag.getFullText(sourceFile); - // Match everything after @tagName and optional whitespace, until we hit */ or @ or end - const match = tagText.match(new RegExp(`@${tagName}\\s+([^\\*@]+?)(?=\\s*(?:\\*\\/|@|$))`)); - if (match && match[1]) { - comment = match[1].trim(); - } else { - comment = ''; - } - } else { - comment = ''; - } + // Fall back to extracting from source text when comment is empty + if (!commentStr && sourceFile) { + const tagText = tag.getFullText(sourceFile); + const match = tagText.match(new RegExp(`@${tagName}\\s+([^\\*@]+?)(?=\\s*(?:\\*\\/|@|$))`)); + commentStr = match?.[1]?.trim() ?? ''; } } switch (tagName) { case 'minLength': { - const value = extractNumericValue(comment); - if (value !== null) { - return { type: 'minLength', value }; - } + const value = extractNumericValue(commentStr); + if (value !== null) return { type: 'minLength', value }; break; } case 'maxLength': { - const value = extractNumericValue(comment); - if (value !== null) { - return { type: 'maxLength', value }; - } + const value = extractNumericValue(commentStr); + if (value !== null) return { type: 'maxLength', value }; break; } case 'min': { - const value = extractNumericValue(comment); - if (value !== null) { - return { type: 'min', value }; - } + const value = extractNumericValue(commentStr); + if (value !== null) return { type: 'min', value }; break; } case 'max': { - const value = extractNumericValue(comment); - if (value !== null) { - return { type: 'max', value }; - } + const value = extractNumericValue(commentStr); + if (value !== null) return { type: 'max', value }; break; } case 'pattern': { - const value = extractStringValue(comment); - if (value) { - return { type: 'pattern', value }; - } + const value = extractStringValue(commentStr); + if (value) return { type: 'pattern', value }; break; } case 'enum': { - const values = extractEnumValues(comment); - if (values.length > 0) { - return { type: 'enum', value: values }; - } + const values = extractEnumValues(commentStr); + if (values.length > 0) return { type: 'enum', value: values }; break; } } @@ -207,9 +187,9 @@ function parseJSDocTag(tag: ts.JSDocTag, sourceFile?: ts.SourceFile): FieldConst function extractNumericValue(comment: string | undefined): number | null { if (!comment) return null; - const match = comment.match(/\d+/); + const match = comment.match(/-?\d+(\.\d+)?/); if (match) { - return parseInt(match[0], 10); + return parseFloat(match[0]); } return null; @@ -238,17 +218,10 @@ function extractStringValue(comment: string | undefined): string | null { function extractEnumValues(comment: string | undefined): string[] { if (!comment) return []; - // Remove leading @enum tag if present - let cleanComment = comment.trim(); - if (cleanComment.startsWith('@enum')) { - cleanComment = cleanComment.substring(5).trim(); - } - // Split by comma and trim each value - const result = cleanComment + return comment + .trim() .split(',') .map((v) => v.trim()) .filter((v) => v.length > 0); - - return result; } diff --git a/tests/core/parser.test.ts b/tests/core/parser.test.ts index ba895a6..6a0ce49 100644 --- a/tests/core/parser.test.ts +++ b/tests/core/parser.test.ts @@ -107,6 +107,48 @@ export interface Product { }); }); + describe('generateMockFromInterface with JSDoc constraints', () => { + const constrainedFile = path.join(testDir, 'constrained-interface.ts'); + + beforeAll(() => { + fs.writeFileSync( + constrainedFile, + `export interface Badge { + /** @maxLength 10 */ + label: string; + /** @min 1 @max 5 */ + level: number; + /** @enum ACTIVE,INACTIVE,PENDING */ + status: string; +}` + ); + }); + + it('should respect @maxLength on string fields', () => { + for (let i = 0; i < 5; i++) { + const mock = generateMockFromInterface(constrainedFile, 'Badge'); + expect(typeof mock.label).toBe('string'); + expect((mock.label as string).length).toBeLessThanOrEqual(10); + } + }); + + it('should respect @min and @max on number fields', () => { + for (let i = 0; i < 5; i++) { + const mock = generateMockFromInterface(constrainedFile, 'Badge'); + expect(typeof mock.level).toBe('number'); + expect(mock.level).toBeGreaterThanOrEqual(1); + expect(mock.level).toBeLessThanOrEqual(5); + } + }); + + it('should respect @enum on string fields', () => { + for (let i = 0; i < 5; i++) { + const mock = generateMockFromInterface(constrainedFile, 'Badge'); + expect(['ACTIVE', 'INACTIVE', 'PENDING']).toContain(mock.status); + } + }); + }); + describe('generateMockArray', () => { it('should generate array of mocks', () => { const mocks = generateMockArray(testFile, 'User');