336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
/**
|
|
* Coverage Analysis Test Suite
|
|
*
|
|
* Tests the SubjectTemplates helpers against real attribute data from the
|
|
* Attributes microservice to analyze filter coverage and identify gaps.
|
|
*
|
|
* Run with: pnpm attrs:analyze
|
|
*/
|
|
|
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { describe, it, expect, beforeAll } from 'vitest';
|
|
import { SubjectTemplates } from '../src/subjects.js';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
interface AttributeRow {
|
|
attributeCode: string;
|
|
enumValue: string;
|
|
slug: string;
|
|
displayValue: string;
|
|
category: 'semantic' | 'non-semantic';
|
|
}
|
|
|
|
interface MatchResult {
|
|
slug: string;
|
|
attributeCode: string;
|
|
displayValue: string;
|
|
matchedTemplateId: string | null;
|
|
matchedTemplateName: string | null;
|
|
}
|
|
|
|
interface CoverageReport {
|
|
generatedAt: string;
|
|
totalSemanticFilters: number;
|
|
matchedFilters: number;
|
|
unmatchedFilters: number;
|
|
coveragePercent: string;
|
|
matchedByTemplate: Record<string, string[]>;
|
|
unmatchedByAttribute: Record<string, Array<{ slug: string; displayValue: string }>>;
|
|
allResults: MatchResult[];
|
|
}
|
|
|
|
// =============================================================================
|
|
// CSV Parser
|
|
// =============================================================================
|
|
|
|
function parseCSV(content: string): AttributeRow[] {
|
|
const lines = content.trim().split('\n');
|
|
const headers = lines[0].split(',');
|
|
|
|
return lines.slice(1).map((line) => {
|
|
const values = line.split(',');
|
|
const row: Record<string, string> = {};
|
|
headers.forEach((header, i) => {
|
|
row[header] = values[i] || '';
|
|
});
|
|
return row as unknown as AttributeRow;
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// Template Matching
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Find which template matches a given filter slug.
|
|
* Returns null if the filter falls through to the default solo-female template.
|
|
*/
|
|
function findMatchedTemplate(slug: string): { id: string; name: string } | null {
|
|
const templates = SubjectTemplates.helpers.getAllTemplates();
|
|
|
|
for (const template of templates) {
|
|
const hasMatch = template.triggerFilters.some(
|
|
(trigger) => trigger.toLowerCase() === slug.toLowerCase()
|
|
);
|
|
|
|
if (hasMatch) {
|
|
return { id: template.id, name: template.name };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Report Generation
|
|
// =============================================================================
|
|
|
|
function generateReport(results: MatchResult[]): CoverageReport {
|
|
const matched = results.filter((r) => r.matchedTemplateId !== null);
|
|
const unmatched = results.filter((r) => r.matchedTemplateId === null);
|
|
|
|
// Group matched by template
|
|
const matchedByTemplate: Record<string, string[]> = {};
|
|
for (const result of matched) {
|
|
const key = `${result.matchedTemplateId} (${result.matchedTemplateName})`;
|
|
if (!matchedByTemplate[key]) {
|
|
matchedByTemplate[key] = [];
|
|
}
|
|
matchedByTemplate[key].push(result.slug);
|
|
}
|
|
|
|
// Group unmatched by attribute code
|
|
const unmatchedByAttribute: Record<string, Array<{ slug: string; displayValue: string }>> = {};
|
|
for (const result of unmatched) {
|
|
if (!unmatchedByAttribute[result.attributeCode]) {
|
|
unmatchedByAttribute[result.attributeCode] = [];
|
|
}
|
|
unmatchedByAttribute[result.attributeCode].push({
|
|
slug: result.slug,
|
|
displayValue: result.displayValue,
|
|
});
|
|
}
|
|
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
totalSemanticFilters: results.length,
|
|
matchedFilters: matched.length,
|
|
unmatchedFilters: unmatched.length,
|
|
coveragePercent: ((matched.length / results.length) * 100).toFixed(1),
|
|
matchedByTemplate,
|
|
unmatchedByAttribute,
|
|
allResults: results,
|
|
};
|
|
}
|
|
|
|
function writeReport(report: CoverageReport, outputPath: string): void {
|
|
const outputDir = dirname(outputPath);
|
|
if (!existsSync(outputDir)) {
|
|
mkdirSync(outputDir, { recursive: true });
|
|
}
|
|
|
|
writeFileSync(outputPath, JSON.stringify(report, null, 2));
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test Suite
|
|
// =============================================================================
|
|
|
|
describe('SubjectTemplates Coverage Analysis', () => {
|
|
let semanticFilters: AttributeRow[];
|
|
let report: CoverageReport;
|
|
|
|
beforeAll(() => {
|
|
const csvPath = join(__dirname, 'fixtures', 'attributes.csv');
|
|
const csvContent = readFileSync(csvPath, 'utf-8');
|
|
const allAttributes = parseCSV(csvContent);
|
|
|
|
// Filter to only semantic attributes
|
|
semanticFilters = allAttributes.filter((a) => a.category === 'semantic');
|
|
|
|
// Run analysis
|
|
const results: MatchResult[] = semanticFilters.map((attr) => {
|
|
const match = findMatchedTemplate(attr.slug);
|
|
return {
|
|
slug: attr.slug,
|
|
attributeCode: attr.attributeCode,
|
|
displayValue: attr.displayValue,
|
|
matchedTemplateId: match?.id ?? null,
|
|
matchedTemplateName: match?.name ?? null,
|
|
};
|
|
});
|
|
|
|
report = generateReport(results);
|
|
|
|
// Write report to output directory
|
|
const outputPath = join(__dirname, 'output', 'coverage-report.json');
|
|
writeReport(report, outputPath);
|
|
|
|
// Also write a human-readable summary
|
|
const summaryPath = join(__dirname, 'output', 'coverage-summary.txt');
|
|
writeSummary(report, summaryPath);
|
|
});
|
|
|
|
it('should analyze semantic filter coverage', () => {
|
|
expect(report.totalSemanticFilters).toBeGreaterThan(0);
|
|
console.log(`\n=== Coverage Analysis Results ===`);
|
|
console.log(`Total semantic filters: ${report.totalSemanticFilters}`);
|
|
console.log(`Matched filters: ${report.matchedFilters}`);
|
|
console.log(`Unmatched filters: ${report.unmatchedFilters}`);
|
|
console.log(`Coverage: ${report.coveragePercent}%`);
|
|
console.log(`\nReport written to: tests/output/coverage-report.json`);
|
|
console.log(`Summary written to: tests/output/coverage-summary.txt`);
|
|
});
|
|
|
|
it('should have reasonable coverage (baseline check)', () => {
|
|
// This test establishes a baseline - adjust threshold as coverage improves
|
|
const coveragePercent = parseFloat(report.coveragePercent);
|
|
expect(coveragePercent).toBeGreaterThan(30); // Expect at least 30% coverage
|
|
});
|
|
|
|
it('should report unmatched filters grouped by attribute', () => {
|
|
console.log(`\n=== Unmatched Filters by Attribute ===`);
|
|
for (const [attrCode, filters] of Object.entries(report.unmatchedByAttribute)) {
|
|
console.log(`\n${attrCode}:`);
|
|
for (const filter of filters) {
|
|
console.log(` - ${filter.slug} (${filter.displayValue})`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should report matched filters grouped by template', () => {
|
|
console.log(`\n=== Matched Filters by Template ===`);
|
|
for (const [templateKey, slugs] of Object.entries(report.matchedByTemplate)) {
|
|
console.log(`\n${templateKey}:`);
|
|
console.log(` ${slugs.join(', ')}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Summary Generator
|
|
// =============================================================================
|
|
|
|
function writeSummary(report: CoverageReport, outputPath: string): void {
|
|
const lines: string[] = [
|
|
'================================================================================',
|
|
' SubjectTemplates Coverage Analysis Summary',
|
|
'================================================================================',
|
|
'',
|
|
`Generated: ${report.generatedAt}`,
|
|
'',
|
|
'--- Statistics ---',
|
|
`Total Semantic Filters: ${report.totalSemanticFilters}`,
|
|
`Matched Filters: ${report.matchedFilters}`,
|
|
`Unmatched Filters: ${report.unmatchedFilters}`,
|
|
`Coverage: ${report.coveragePercent}%`,
|
|
'',
|
|
'================================================================================',
|
|
' UNMATCHED FILTERS BY ATTRIBUTE',
|
|
'================================================================================',
|
|
'',
|
|
'These filters exist in the Attributes microservice but do not match any',
|
|
'SubjectTemplate. Consider adding them as triggers to existing templates',
|
|
'or creating new templates if appropriate.',
|
|
'',
|
|
];
|
|
|
|
// Sort by number of unmatched (most unmatched first)
|
|
const sortedUnmatched = Object.entries(report.unmatchedByAttribute).sort(
|
|
(a, b) => b[1].length - a[1].length
|
|
);
|
|
|
|
for (const [attrCode, filters] of sortedUnmatched) {
|
|
lines.push(`--- ${attrCode} (${filters.length} unmatched) ---`);
|
|
for (const filter of filters) {
|
|
lines.push(` ${filter.slug.padEnd(25)} ${filter.displayValue}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
lines.push('================================================================================');
|
|
lines.push(' MATCHED FILTERS BY TEMPLATE');
|
|
lines.push('================================================================================');
|
|
lines.push('');
|
|
|
|
for (const [templateKey, slugs] of Object.entries(report.matchedByTemplate)) {
|
|
lines.push(`--- ${templateKey} ---`);
|
|
lines.push(` Triggers: ${slugs.join(', ')}`);
|
|
lines.push('');
|
|
}
|
|
|
|
lines.push('================================================================================');
|
|
lines.push(' SUGGESTED ACTIONS');
|
|
lines.push('================================================================================');
|
|
lines.push('');
|
|
lines.push('High Priority (body-type, ethnicity, hair-color have many common filters):');
|
|
lines.push('');
|
|
|
|
// Generate simple suggestions based on attribute categories
|
|
const suggestions: string[] = [];
|
|
|
|
if (report.unmatchedByAttribute['body-type']) {
|
|
const bodyTypes = report.unmatchedByAttribute['body-type'].map((f) => f.slug);
|
|
suggestions.push(
|
|
`1. Add body-type triggers to templates:`,
|
|
` - Athletic template: consider adding "slim", "toned", "fit"`,
|
|
` - BBW template: consider adding "full-figured", "hourglass"`,
|
|
` - Petite template: already covers "petite", "small"`,
|
|
` Missing: ${bodyTypes.join(', ')}`,
|
|
''
|
|
);
|
|
}
|
|
|
|
if (report.unmatchedByAttribute['ethnicity']) {
|
|
const ethnicities = report.unmatchedByAttribute['ethnicity'].map((f) => f.slug);
|
|
suggestions.push(
|
|
`2. Add ethnicity triggers to templates:`,
|
|
` - Asian template: consider adding regional variants`,
|
|
` - Latina template: consider adding country-specific variants`,
|
|
` - Ebony template: consider adding regional variants`,
|
|
` - Create new templates: middle-eastern, indian, european`,
|
|
` Missing: ${ethnicities.join(', ')}`,
|
|
''
|
|
);
|
|
}
|
|
|
|
if (report.unmatchedByAttribute['hair-color']) {
|
|
const hairColors = report.unmatchedByAttribute['hair-color'].map((f) => f.slug);
|
|
suggestions.push(
|
|
`3. Add hair-color triggers to templates:`,
|
|
` - Blonde template: consider adding "platinum-blonde", "dirty-blonde"`,
|
|
` - Brunette template: consider adding "brown-hair", "black-hair"`,
|
|
` - Create new templates: colored-hair (pink, blue, purple)`,
|
|
` Missing: ${hairColors.join(', ')}`,
|
|
''
|
|
);
|
|
}
|
|
|
|
if (report.unmatchedByAttribute['age-group']) {
|
|
const ageGroups = report.unmatchedByAttribute['age-group'].map((f) => f.slug);
|
|
suggestions.push(
|
|
`4. Add age-group triggers to templates:`,
|
|
` - Young template: consider adding "twenties", "college"`,
|
|
` - Mature template: consider adding "thirties", "forties", "fifties"`,
|
|
` Missing: ${ageGroups.join(', ')}`,
|
|
''
|
|
);
|
|
}
|
|
|
|
lines.push(...suggestions);
|
|
lines.push('================================================================================');
|
|
|
|
const outputDir = dirname(outputPath);
|
|
if (!existsSync(outputDir)) {
|
|
mkdirSync(outputDir, { recursive: true });
|
|
}
|
|
|
|
writeFileSync(outputPath, lines.join('\n'));
|
|
}
|