/** * cli:scaffold-tests-from-ac — generate.ts * * Pure code-emission. Takes parsed UCs and a spec → returns GeneratedFile[]. * No filesystem (index.ts writes), no LLM. * * One file per section that has ≥ 1 AC. Layout: * * Tests/{ModuleP}/Acceptance/{SectionP}AcceptanceTests.cs * * Each AC becomes ONE [Fact] with: * - [Trait("Category","Acceptance")] [Trait("Module",…)] [Trait("AC", )] * - Method name AC_NN_{slug-of-first-words} * - Verbatim AC text in a // comment * - // TODO[AC-NN]: ... + Assert.Fail("…") — Phase 5 fills this in. * * The TODO + Assert.Fail combo guarantees the test fails until a human/LLM * implements the assertion. DEV-TEST-001 (Wave 3) audits that no `// TODO[AC-` * remains on merge. */ import type { GeneratedFile, ScaffoldTestsFromAcSpec, UcWithAc, } from './types.js' /** Convert kebab/snake `opportunites-rdv` → `OpportunitesRdv` (PascalCase). */ function pascal(s: string): string { return s .split(/[-_\s]+/) .filter(Boolean) .map(p => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()) .join('') } /** Slug AC text → snake-fragment usable in a C# method name. */ function methodSlug(acText: string, maxWords = 7): string { // Strip accents const noAccents = acText.normalize('NFD').replace(/[̀-ͯ]/g, '') // Keep alphanumerics and spaces; collapse the rest const cleaned = noAccents .replace(/[^a-zA-Z0-9 ]/g, ' ') .replace(/\s+/g, ' ') .trim() const words = cleaned.split(' ').slice(0, maxWords) return words .map(w => w.toLowerCase()) .join('_') .slice(0, 80) } /** Compute the global AC reference `#AC-NN`. */ function globalAcRef(ucCode: string, localId: string): string { return `${ucCode}#${localId}` } /** Build the default namespace when none is supplied. Section-classified to match * the `Tests///
/` folder (replaces the old flat `.Acceptance` * bucket — `[Trait("Category","Acceptance")]` is the real acceptance marker). */ function defaultNamespace(appCode: string, module: string, section: string): string { return `${pascal(appCode)}.Tests.${pascal(module)}.${pascal(section)}` } /** Emit ONE Fact for ONE AC. Indented for the class body (4-space). */ function emitFact(ucCode: string, ucTitle: string, localId: string, acText: string, moduleP: string): string { const safeName = `${localId.replace('-', '_')}_${methodSlug(acText)}`.replace(/__+/g, '_') const ref = globalAcRef(ucCode, localId) // Escape embedded " in AC text for use inside a "..." literal const acEscaped = acText.replace(/\\/g, '\\\\').replace(/"/g, '\\"') return [ ` [Fact]`, ` [Trait("Category", "Acceptance")]`, ` [Trait("Module", "${moduleP}")]`, ` [Trait("AC", "${ref}")]`, ` public async Task ${safeName}()`, ` {`, ` // AC: ${acText}`, ` // UC: ${ucCode} — ${ucTitle}`, ` // TODO[${localId}]: implement assertion (see ${ucCode}#${localId} in use-case.md).`, ` // The /ba-develop Phase 5 subagent fills this body using:`, ` // - the AC text above (what to assert),`, ` // - the controller + DTO scaffolded in Phases 2-3,`, ` // - the WebApplicationFactory _factory fixture below.`, ` await Task.CompletedTask;`, ` Assert.Fail("${ref}: not yet implemented — Phase 5 must fill this body. Remove this line + the TODO once implemented.");`, ` }`, ``, ].join('\n') } /** Emit ONE full test class (one section's UCs with ACs). */ function emitTestClass( ns: string, moduleP: string, sectionP: string, ucsForSection: UcWithAc[] ): string { const className = `${sectionP}AcceptanceTests` const header = [ `// `, `// Each [Fact] in this file is one Acceptance Criterion (AC) declared under a UC`, `// in .smartstack/ba/${moduleP.toLowerCase()}/${sectionP.toLowerCase().replace(/([A-Z])/g, '-$1').replace(/^-/, '')}/use-case.md.`, `// Phase 5 of /ba-develop fills the // TODO[AC-NN] body with real assertions.`, `// Audit DEV-TEST-001 (Wave 3) blocks merge if any // TODO[AC- marker remains.`, ``, `using System.Net;`, `using System.Net.Http.Json;`, `using FluentAssertions;`, `using Microsoft.AspNetCore.Mvc.Testing;`, `using Xunit;`, ``, `namespace ${ns};`, ``, `public class ${className} : IClassFixture>`, `{`, ` private readonly WebApplicationFactory _factory;`, ` private readonly HttpClient _client;`, ``, ` public ${className}(WebApplicationFactory factory)`, ` {`, ` _factory = factory;`, ` _client = factory.CreateClient();`, ` }`, ``, ].join('\n') const ucBlocks: string[] = [] for (const uc of ucsForSection) { if (uc.acs.length === 0) continue ucBlocks.push(` // ==== ${uc.ucCode} — ${uc.title} ====\n`) for (const ac of uc.acs) { ucBlocks.push(emitFact(uc.ucCode, uc.title, ac.localId, ac.text, moduleP)) } } const footer = `}\n` return header + ucBlocks.join('\n') + footer } export interface GenerateInput { spec: ScaffoldTestsFromAcSpec ucs: UcWithAc[] } export interface GenerateOutput { files: GeneratedFile[] ucsScanned: number acsParsed: number factsEmitted: number } /** * Group UCs by section, emit one file per section that has ≥ 1 AC, return them. * Caller (index.ts) writes them to disk (unless --dry-run). */ export function generate(input: GenerateInput): GenerateOutput { const { spec, ucs } = input const moduleP = pascal(spec.module) // Business app (kebab) → PascalCase; classifies the folder + namespace, matching // the backend test layout. The .NET test-project root stays `${App}.Tests`. const appP = pascal(spec.appCode) // Group by section const bySection = new Map() for (const uc of ucs) { const key = uc.sectionFolder if (!bySection.has(key)) bySection.set(key, []) bySection.get(key)!.push(uc) } const files: GeneratedFile[] = [] let factsEmitted = 0 for (const [sectionFolder, sectionUcs] of bySection.entries()) { const totalAcs = sectionUcs.reduce((n, uc) => n + uc.acs.length, 0) if (totalAcs === 0) continue const sectionP = pascal(sectionFolder) const ns = spec.namespace ?? defaultNamespace(spec.appCode, spec.module, sectionFolder) const content = emitTestClass(ns, moduleP, sectionP, sectionUcs) const relPath = `Tests/${appP}/${moduleP}/${sectionP}/${sectionP}AcceptanceTests.cs` files.push({ path: relPath, content }) factsEmitted += totalAcs } const acsParsed = ucs.reduce((n, uc) => n + uc.acs.length, 0) return { files, ucsScanned: ucs.length, acsParsed, factsEmitted, } } /** * Pre-classification locations of the acceptance test files: they used to live in * the per-module `Tests//Acceptance/` bucket. Maps each generated * (classified) path back so the CLI deletes the stale copy — re-running * /ba-develop MOVES the section's acceptance tests instead of leaving a stale * file referencing the now-moved namespaces. */ export function legacyPaths(spec: ScaffoldTestsFromAcSpec, files: GeneratedFile[]): string[] { const appP = pascal(spec.appCode) const moduleP = pascal(spec.module) const newPrefix = `Tests/${appP}/${moduleP}/` return files.map(f => { if (!f.path.startsWith(newPrefix)) return f.path const tail = f.path.slice(newPrefix.length) //
/
AcceptanceTests.cs const base = tail.slice(tail.indexOf('/') + 1) //
AcceptanceTests.cs return `Tests/${moduleP}/Acceptance/${base}` }) } // Re-export helpers for tests export { pascal, methodSlug, globalAcRef, defaultNamespace }