diff --git a/package.json b/package.json index 28c8399..7029b89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "@iklab/testkit", - "version": "1.0.0", + + "version": "1.1.0", "description": "Test quality toolkit — boundary values, flakiness prediction, duplicate detection, static test scanner.", "author": "Ihor Kosheliev", "license": "MIT", diff --git a/src/__tests__/boundaries.test.ts b/src/__tests__/boundaries.test.ts index 1d2f105..a28f9e9 100644 --- a/src/__tests__/boundaries.test.ts +++ b/src/__tests__/boundaries.test.ts @@ -464,3 +464,39 @@ describe("boundaries — structure", () => { } }); }); + +describe("boundaries.custom", () => { + it("returns provided values", () => { + const result = boundaries.custom({ + valid: [18, 25, 65], + invalid: [-1, 0, 17, 151], + boundary: [18, 150], + }); + expect(result.valid).toEqual([18, 25, 65]); + expect(result.invalid).toEqual([-1, 0, 17, 151]); + expect(result.boundary).toEqual([18, 150]); + }); + + it("defaults boundary to empty array", () => { + const result = boundaries.custom({ + valid: ["a"], + invalid: [""], + }); + expect(result.boundary).toEqual([]); + }); + + it("throws on missing valid array", () => { + expect(() => boundaries.custom({ valid: null as any, invalid: [] })).toThrow(TypeError); + }); + + it("throws on missing invalid array", () => { + expect(() => boundaries.custom({ valid: [], invalid: null as any })).toThrow(TypeError); + }); + + it("works with testEach", () => { + const { testEach } = require("../test-each"); + const result = boundaries.custom({ valid: [1], invalid: [0], boundary: [1] }); + const rows = testEach(result); + expect(rows.length).toBeGreaterThan(0); + }); +}); diff --git a/src/__tests__/coverage.test.ts b/src/__tests__/coverage.test.ts new file mode 100644 index 0000000..a8d51d9 --- /dev/null +++ b/src/__tests__/coverage.test.ts @@ -0,0 +1,64 @@ +import { coverage } from "../coverage"; + +describe("coverage", () => { + it("matches tests to requirements", () => { + const result = coverage( + ["should login with valid credentials", "should show error for wrong password"], + ["User can login", "User sees error on invalid password", "User can reset password"], + { threshold: 0.2 } + ); + expect(result.coveragePercent).toBe(67); + expect(result.covered).toHaveLength(2); + expect(result.uncovered).toEqual(["User can reset password"]); + }); + + it("returns 100% when all requirements covered", () => { + const result = coverage( + ["should create user", "should delete user"], + ["Create user", "Delete user"] + ); + expect(result.coveragePercent).toBe(100); + expect(result.uncovered).toHaveLength(0); + }); + + it("returns 0% when nothing matches", () => { + const result = coverage( + ["should render homepage"], + ["User can upload avatar"] + ); + expect(result.coveragePercent).toBe(0); + expect(result.uncovered).toHaveLength(1); + }); + + it("handles empty arrays", () => { + expect(coverage([], []).coveragePercent).toBe(100); + expect(coverage(["test"], []).coveragePercent).toBe(100); + expect(coverage([], ["req"]).coveragePercent).toBe(0); + }); + + it("respects custom threshold", () => { + const result = coverage( + ["should show products"], + ["Display product catalog"], + { threshold: 0.5 } + ); + // Higher threshold = fewer matches + expect(result.coveragePercent).toBeLessThanOrEqual(100); + }); + + it("provides mapping with matched tests", () => { + const result = coverage( + ["should create user account", "should validate email"], + ["User registration"], + { threshold: 0.2 } + ); + expect(result.mapping).toHaveLength(1); + expect(result.mapping[0].requirement).toBe("User registration"); + expect(result.mapping[0].covered).toBe(true); + }); + + it("throws on invalid input", () => { + expect(() => coverage(null as any, [])).toThrow(TypeError); + expect(() => coverage([], null as any)).toThrow(TypeError); + }); +}); diff --git a/src/__tests__/suggest.test.ts b/src/__tests__/suggest.test.ts new file mode 100644 index 0000000..5e7fa1a --- /dev/null +++ b/src/__tests__/suggest.test.ts @@ -0,0 +1,43 @@ +import { suggest } from "../suggest"; + +describe("suggest", () => { + it("suggests negative case for create operations", () => { + const result = suggest("should create a new user"); + expect(result.suggestions.some(s => s.includes("negative"))).toBe(true); + }); + + it("suggests auth for login-related tests", () => { + const result = suggest("should login with valid credentials"); + expect(result.suggestions.some(s => s.includes("auth"))).toBe(true); + }); + + it("suggests boundary for search operations", () => { + const result = suggest("should search products by name"); + expect(result.suggestions.some(s => s.includes("boundary") || s.includes("special characters"))).toBe(true); + }); + + it("suggests file edge cases for upload operations", () => { + const result = suggest("should upload user avatar"); + expect(result.suggestions.some(s => s.includes("file") || s.includes("oversized"))).toBe(true); + }); + + it("suggests error handling for payment operations", () => { + const result = suggest("should process payment checkout"); + expect(result.suggestions.some(s => s.includes("error") || s.includes("fail"))).toBe(true); + }); + + it("returns generic suggestions for vague descriptions", () => { + const result = suggest("should work"); + expect(result.suggestions.length).toBeGreaterThan(0); + }); + + it("returns generic suggestions for empty input", () => { + const result = suggest(""); + expect(result.suggestions).toHaveLength(4); + }); + + it("score reflects number of suggestions", () => { + const result = suggest("should create user"); + expect(result.score).toBe(result.suggestions.length); + }); +}); diff --git a/src/boundaries.ts b/src/boundaries.ts index 31980a1..f382b8d 100644 --- a/src/boundaries.ts +++ b/src/boundaries.ts @@ -282,4 +282,14 @@ export const boundaries = { password: passwordBoundaries, phone: phoneBoundaries, uuid: uuidBoundaries, + custom(rules: { valid: unknown[]; invalid: unknown[]; boundary?: unknown[] }): BoundaryResult { + if (!rules || !Array.isArray(rules.valid) || !Array.isArray(rules.invalid)) { + throw new TypeError("custom() requires { valid: unknown[], invalid: unknown[] }"); + } + return { + valid: [...rules.valid], + invalid: [...rules.invalid], + boundary: rules.boundary ? [...rules.boundary] : [], + }; + }, }; diff --git a/src/coverage.ts b/src/coverage.ts new file mode 100644 index 0000000..858f09b --- /dev/null +++ b/src/coverage.ts @@ -0,0 +1,48 @@ +import type { CoverageResult, CoverageMapping } from "./types"; + +export function coverage( + tests: string[], + requirements: string[], + options?: { threshold?: number } +): CoverageResult { + const threshold = Math.max(0, Math.min(1, options?.threshold ?? 0.3)); + + if (!Array.isArray(tests) || !Array.isArray(requirements)) { + throw new TypeError("coverage() requires arrays of strings"); + } + + const mapping: CoverageMapping[] = requirements.map((req) => { + const matchedTests = tests + .map((test, idx) => ({ test, idx, sim: similarity(req, test) })) + .filter((m) => m.sim >= threshold) + .sort((a, b) => b.sim - a.sim) + .map((m) => m.test); + return { requirement: req, matchedTests, covered: matchedTests.length > 0 }; + }); + + const covered = mapping.filter((m) => m.covered).map((m) => m.requirement); + const uncovered = mapping.filter((m) => !m.covered).map((m) => m.requirement); + const coveragePercent = requirements.length > 0 + ? Math.round((covered.length / requirements.length) * 100) + : 100; + + return { covered, uncovered, coveragePercent, mapping }; +} + +// Local Jaccard similarity (same algorithm as duplicates.ts but decoupled) +function tokenize(text: string): Set { + const STOP_WORDS = new Set(["a","an","the","is","are","was","were","be","been","being","have","has","had","do","does","did","will","would","could","should","may","might","shall","can","need","must","to","of","in","for","on","with","at","by","from","as","into","about","between","through","after","before","during","and","but","or","not","no","it","its","this","that","these","those","i","we","you","he","she","they","my","our","your"]); + return new Set( + text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 1 && !STOP_WORDS.has(w)) + ); +} + +function similarity(a: string, b: string): number { + const setA = tokenize(a); + const setB = tokenize(b); + if (setA.size === 0 || setB.size === 0) return 0; + let intersection = 0; + for (const token of setA) if (setB.has(token)) intersection++; + const union = new Set([...setA, ...setB]).size; + return union > 0 ? intersection / union : 0; +} diff --git a/src/index.ts b/src/index.ts index 1fc7565..7357d29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,8 @@ export { boundaries } from "./boundaries"; export { flaky } from "./flaky"; export { testEach } from "./test-each"; export { detectDuplicates } from "./duplicates"; +export { coverage } from "./coverage"; +export { suggest } from "./suggest"; export type { BoundaryResult, @@ -18,4 +20,7 @@ export type { DuplicateOptions, TestEachOptions, TestEachRow, + CoverageResult, + CoverageMapping, + SuggestionResult, } from "./types"; diff --git a/src/suggest.ts b/src/suggest.ts new file mode 100644 index 0000000..871a7b4 --- /dev/null +++ b/src/suggest.ts @@ -0,0 +1,52 @@ +import type { SuggestionResult } from "./types"; + +const PATTERNS: { pattern: RegExp; category: string; suggestion: string }[] = [ + { pattern: /^should\s+(create|add|save|insert|post|submit|register|sign.?up)/i, category: "negative", suggestion: "Consider negative case: what happens with invalid or missing input?" }, + { pattern: /^should\s+(update|edit|modify|change|patch)/i, category: "negative", suggestion: "Consider negative case: what if the resource doesn't exist?" }, + { pattern: /^should\s+(delete|remove|destroy)/i, category: "negative", suggestion: "Consider negative case: what if the resource is already deleted?" }, + { pattern: /^should\s+(display|show|render|list|get|fetch|load|read)/i, category: "empty", suggestion: "Consider edge case: what if the result is empty or null?" }, + { pattern: /^should\s+(search|filter|sort|find)/i, category: "boundary", suggestion: "Consider boundary: what about empty query, very long query, or special characters?" }, + { pattern: /login|auth|sign.?in|session|token/i, category: "auth", suggestion: "Consider auth: what if the user is not authenticated or session expired?" }, + { pattern: /upload|file|image|attachment|document/i, category: "file", suggestion: "Consider edge case: what about empty files, oversized files, or unsupported formats?" }, + { pattern: /payment|checkout|order|cart|price|billing/i, category: "error", suggestion: "Consider error handling: what if payment fails or times out?" }, + { pattern: /email|notification|sms|message/i, category: "async", suggestion: "Consider async: what if delivery fails or is delayed?" }, + { pattern: /permission|role|access|admin/i, category: "auth", suggestion: "Consider authorization: what if user lacks required permissions?" }, + { pattern: /pagination|page|scroll|infinite|load.?more/i, category: "boundary", suggestion: "Consider boundary: first page, last page, empty page, page beyond max." }, + { pattern: /concurrent|parallel|simultaneous|race/i, category: "concurrency", suggestion: "Consider concurrency: what if two users perform the same action simultaneously?" }, +]; + +// Generic suggestions when no specific patterns match +const GENERIC: string[] = [ + "Consider negative case: what happens when input is invalid?", + "Consider edge case: empty or null values.", + "Consider boundary values: min, max, zero, one.", + "Consider error handling: what if the operation fails?", +]; + +export function suggest(description: string): SuggestionResult { + if (typeof description !== "string" || description.trim().length === 0) { + return { suggestions: GENERIC, score: GENERIC.length }; + } + + const matched = new Map(); + for (const p of PATTERNS) { + if (p.pattern.test(description) && !matched.has(p.category)) { + matched.set(p.category, p.suggestion); + } + } + + // Always check for missing common aspects + const lower = description.toLowerCase(); + if (!lower.includes("error") && !lower.includes("fail") && !lower.includes("invalid") && !matched.has("negative")) { + matched.set("negative", "Consider negative case: what happens with invalid input?"); + } + if (!lower.includes("empty") && !lower.includes("null") && !lower.includes("zero") && !lower.includes("none") && !matched.has("empty")) { + matched.set("empty", "Consider edge case: empty, null, or zero values."); + } + if (!lower.includes("boundary") && !lower.includes("limit") && !lower.includes("max") && !lower.includes("min") && !matched.has("boundary")) { + matched.set("boundary", "Consider boundary values for relevant fields."); + } + + const suggestions = Array.from(matched.values()); + return { suggestions, score: suggestions.length }; +} diff --git a/src/types.ts b/src/types.ts index eb40c24..0dec269 100644 --- a/src/types.ts +++ b/src/types.ts @@ -96,3 +96,23 @@ export interface TestEachOptions { /** A single test.each row: [label, input, expected]. */ export type TestEachRow = [string, unknown, boolean]; + +// Coverage +export interface CoverageMapping { + requirement: string; + matchedTests: string[]; + covered: boolean; +} + +export interface CoverageResult { + covered: string[]; + uncovered: string[]; + coveragePercent: number; + mapping: CoverageMapping[]; +} + +// Suggestions +export interface SuggestionResult { + suggestions: string[]; + score: number; +}