import { describe, it, expect } from 'vitest'; import { renderEntity, patchTimeEntryRefs, backfillSeamScaffold, ensureUsing, hasSeamMarkers, lambdaParam, } from '../generate.js'; import { validate } from '../validate.js'; import { BEGIN_MARKER, END_MARKER, type TimeEntryRefsSpec, type TimeEntryRefEntity } from '../types.js'; function entity(over: Partial = {}): TimeEntryRefEntity { return { entityName: 'Test.Domain.Entities.Project', refType: 'project', label: 'Projets', icon: 'FolderKanban', display: 'Name', tenantScoped: true, order: 100, ...over, }; } function spec(over: Partial = {}): TimeEntryRefsSpec { return { appCode: 'Test', contextType: 'ExtensionsDbContext', projectPath: '/tmp/test', entities: [entity()], ...over, }; } const DI_WITH_MARKERS = `using Microsoft.Extensions.DependencyInjection; using SmartStack.Infrastructure.Services.TimeEntryRefs; using Test.Infrastructure.Persistence; namespace Test.Infrastructure; public static class DependencyInjection { public static IServiceCollection AddTestInfrastructure(this IServiceCollection services, IConfiguration configuration) { services.AddSmartStackExtensionDbContext(configuration); services.AddExtensionTimeEntryRefs(refs => { // <<< TIME-ENTRY-REFS-DI BEGIN >>> // refs.Entity("myentity", "My entities", "FolderKanban") // .WithDisplay(x => x.Name); // <<< TIME-ENTRY-REFS-DI END >>> }); return services; } } `; const DI_NO_MARKERS = `using Microsoft.Extensions.DependencyInjection; using Test.Infrastructure.Persistence; namespace Test.Infrastructure; public static class DependencyInjection { public static IServiceCollection AddTestInfrastructure(this IServiceCollection services, IConfiguration configuration) { services.AddSmartStackExtensionDbContext(configuration); return services; } } `; describe('scaffold-time-entry-refs / lambdaParam', () => { it('derives the single-char lambda parameter from the simple name', () => { expect(lambdaParam('Test.Domain.Entities.Project')).toBe('p'); expect(lambdaParam('Mandate')).toBe('m'); }); }); describe('scaffold-time-entry-refs / renderEntity', () => { it('renders the mandatory display + tenant scope', () => { const out = renderEntity(entity()); expect(out).toContain('refs.Entity("project", "Projets", "FolderKanban")'); expect(out).toContain('.WithDisplay(p => p.Name)'); expect(out).toContain('.TenantScoped()'); expect(out.trimEnd().endsWith(';')).toBe(true); }); it('emits subtitle, order and a per-dimension permission when present', () => { const out = renderEntity(entity({ subtitle: 'Code', permission: 'projets.liste.read', order: 10 })); expect(out).toContain('.WithSubtitle(p => p.Code)'); expect(out).toContain('.RequirePermission("projets.liste.read")'); expect(out).toContain('.Order(10)'); }); it('wraps a bare activeWhen body with the entity parameter', () => { const out = renderEntity(entity({ activeWhen: 'p.Status == ProjectStatus.Open' })); expect(out).toContain('.ActiveWhen(p => p.Status == ProjectStatus.Open)'); }); it('passes a full activeWhen lambda through verbatim', () => { const out = renderEntity(entity({ activeWhen: 'q => q.IsActive && !q.Archived' })); expect(out).toContain('.ActiveWhen(q => q.IsActive && !q.Archived)'); }); it('omits TenantScoped and Order when not requested / default', () => { const out = renderEntity(entity({ tenantScoped: false, order: 100 })); expect(out).not.toContain('.TenantScoped()'); expect(out).not.toContain('.Order('); expect(out).not.toContain('.WithSubtitle('); expect(out).not.toContain('.ActiveWhen('); expect(out).not.toContain('.RequirePermission('); }); }); describe('scaffold-time-entry-refs / patch (markers present)', () => { it('replaces the placeholder content between the markers', () => { const next = patchTimeEntryRefs(DI_WITH_MARKERS, spec()); expect(next).not.toBeNull(); expect(next!).toContain('refs.Entity("project"'); expect(next!).not.toContain('MyEntity'); // placeholder gone expect(next!).toContain(BEGIN_MARKER); expect(next!).toContain(END_MARKER); // exactly one marker pair expect(next!.match(new RegExp(BEGIN_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))!.length).toBe(1); }); it('is idempotent — a second identical patch is a no-op', () => { const once = patchTimeEntryRefs(DI_WITH_MARKERS, spec())!; const twice = patchTimeEntryRefs(once, spec()); expect(twice).toBeNull(); }); }); describe('scaffold-time-entry-refs / patch (no markers)', () => { it('inserts the full wrapper before return services; and adds the using', () => { const next = patchTimeEntryRefs(DI_NO_MARKERS, spec()); expect(next).not.toBeNull(); expect(next!).toContain('using SmartStack.Infrastructure.Services.TimeEntryRefs;'); expect(next!).toContain('services.AddExtensionTimeEntryRefs(refs =>'); expect(next!).toContain('refs.Entity("project"'); // wrapper sits before the return expect(next!.indexOf('AddExtensionTimeEntryRefs')).toBeLessThan(next!.indexOf('return services;')); }); it('ensureUsing does not duplicate an existing using', () => { const once = ensureUsing(DI_NO_MARKERS, 'SmartStack.Infrastructure.Services.TimeEntryRefs'); const twice = ensureUsing(once, 'SmartStack.Infrastructure.Services.TimeEntryRefs'); expect(twice.match(/using SmartStack\.Infrastructure\.Services\.TimeEntryRefs;/g)!.length).toBe(1); }); }); describe('scaffold-time-entry-refs / backfillSeamScaffold (discover / ss upgrade)', () => { it('inserts the COMMENTED seam wrapper when a project predates the seam — registers nothing', () => { const next = backfillSeamScaffold(DI_NO_MARKERS, 'ExtensionsDbContext'); expect(next).not.toBeNull(); expect(next!).toContain('using SmartStack.Infrastructure.Services.TimeEntryRefs;'); expect(next!).toContain('services.AddExtensionTimeEntryRefs(refs =>'); expect(next!).toContain(BEGIN_MARKER); expect(next!).toContain(END_MARKER); // every registration line inside is commented out — no real dimension registered. expect(next!).toContain('// refs.Entity'); expect(next!).not.toMatch(/^\s*refs\.Entity { expect(backfillSeamScaffold(DI_WITH_MARKERS, 'ExtensionsDbContext')).toBeNull(); }); }); describe('scaffold-time-entry-refs / validate', () => { it('applies defaults (contextType, tenantScoped, order)', () => { const r = validate({ appCode: 'Test', projectPath: '/tmp', entities: [{ entityName: 'X.Project', refType: 'project', label: 'P', icon: 'I', display: 'Name' }] }); expect(r.valid).toBe(true); expect(r.data!.contextType).toBe('ExtensionsDbContext'); expect(r.data!.entities[0].tenantScoped).toBe(true); expect(r.data!.entities[0].order).toBe(100); }); it('rejects a missing display and a non-kebab refType', () => { const r = validate({ appCode: 'Test', projectPath: '/tmp', entities: [{ entityName: 'X', refType: 'Project_X', label: 'X', icon: 'I' }] }); expect(r.valid).toBe(false); expect(r.errors.join(' ')).toMatch(/display|refType/i); }); it('rejects an empty entities array', () => { const r = validate({ appCode: 'Test', projectPath: '/tmp', entities: [] }); expect(r.valid).toBe(false); }); });