/** * derived-field.test.ts — the « colonne dérivée » engine (§ the fil rouge): * a read member whose value lives behind a navigation or inside a dated * child. The four client workarounds it closes: PlateNumber (open * registration), CurrentMileage (latest reading), OfficeName (open site * assignment), Email (User navigation). */ import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' import { validate } from '../validate.js' import { ScaffoldBusinessInputSchema, type ScaffoldBusinessInput } from '../types.js' function spec(fields: object[]): ScaffoldBusinessInput { return ScaffoldBusinessInputSchema.parse({ name: 'Vehicle', pluralName: 'Vehicles', module: 'parc', appCode: 'Demo', applicationCode: 'flotte', namespace: 'Demo', // Display is not this suite's subject — Vehicle has no Name-family column // (its identity is the derived plate), so opt out consciously (§26: the // silent first-string/GUID fallback is gone). displayNameExpr: 'Id', fields, businessRules: [], customActions: [], projectPath: '/tmp/project', }) } const svcOf = (s: ScaffoldBusinessInput) => generate(s).find(f => f.path.endsWith('/VehicleService.cs'))!.content const dtoOf = (s: ScaffoldBusinessInput) => generate(s).find(f => /DTOs\/.*\.cs$/.test(f.path))!.content describe('scaffold-business / derived fields — the fil-rouge projections', () => { it('child open-period: the open registration plate projects EF-translatably, nullable', () => { const s = spec([ { name: 'Vin', type: 'string', required: true }, { name: 'PlateNumber', type: 'string', required: false, derived: { kind: 'child', collection: 'Registrations', select: 'PlateNumber', pick: { mode: 'open-period', endField: 'EndDate' } } }, ]) const svc = svcOf(s) expect(svc).toContain('x.Registrations.Where(c => c.EndDate == null).Select(c => c.PlateNumber).FirstOrDefault()') // Read surfaces carry it; write surfaces never do. const dto = dtoOf(s) expect(dto).toMatch(/record VehicleListDto\([\s\S]*?string\? PlateNumber,/) expect(dto).not.toMatch(/record CreateVehicleDto\([\s\S]*?PlateNumber/) expect(dto).not.toMatch(/record UpdateVehicleDto\([\s\S]*?PlateNumber/) }) it('child latest: the last odometer reading projects with a nullable value-type cast', () => { const s = spec([ { name: 'Vin', type: 'string', required: true }, { name: 'CurrentMileage', type: 'int', required: false, derived: { kind: 'child', collection: 'OdometerReadings', select: 'Mileage', pick: { mode: 'latest', dateField: 'ReadingDate' } } }, ]) expect(svcOf(s)).toContain('x.OdometerReadings.OrderByDescending(c => c.ReadingDate).Select(c => (int?)c.Mileage).FirstOrDefault()') expect(dtoOf(s)).toMatch(/int\? CurrentMileage,/) }) it('nav: a nullable-FK hop guards the read (the Email-from-User shape)', () => { const s = spec([ { name: 'UserId', type: 'guid', required: false }, { name: 'Email', type: 'string', required: false, derived: { kind: 'nav', nav: 'User', property: 'Email' } }, ]) expect(svcOf(s)).toContain('(x.UserId != null ? x.User!.Email : null)') }) it('nav: a required FK reads straight through', () => { const s = spec([ { name: 'OfficeId', type: 'guid', required: true }, { name: 'OfficeName', type: 'string', required: false, derived: { kind: 'nav', nav: 'Office', property: 'Name' } }, ]) expect(svcOf(s)).toContain('x.Office.Name') }) it('a derived member is never searchable nor a sort arm (v1 boundary)', () => { const s = spec([ { name: 'Vin', type: 'string', required: true }, { name: 'PlateNumber', type: 'string', required: false, derived: { kind: 'child', collection: 'Registrations', select: 'PlateNumber', pick: { mode: 'open-period', endField: 'EndDate' } } }, ]) const svc = svcOf(s) const searchIdx = svc.indexOf('EF.Functions.Like(x.Vin') expect(searchIdx).toBeGreaterThan(-1) expect(svc).not.toContain('EF.Functions.Like(x.Registrations') expect(svc).not.toContain('"platenumber" => ') }) it('validate: derived × formula/source is refused, and derived + required too', () => { const both = validate({ ...spec([{ name: 'Vin', type: 'string', required: true }]), fields: [{ name: 'X', type: 'string', required: false, formula: 'Vin', derived: { kind: 'nav', nav: 'User', property: 'Email' } }], }) expect(both.valid).toBe(false) expect(both.errors.some(e => /mutually exclusive/.test(e))).toBe(true) const req = validate({ ...spec([{ name: 'Vin', type: 'string', required: true }]), fields: [{ name: 'X', type: 'string', required: true, derived: { kind: 'nav', nav: 'User', property: 'Email' } }], }) expect(req.valid).toBe(false) expect(req.errors.some(e => /cannot be required/.test(e))).toBe(true) }) })