104 lines
5.6 KiB
TypeScript
104 lines
5.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { SourceExtractionResult } from '../../src/domain/source-extraction';
|
|
import { mapFindingCandidates } from '../../src/importers/candidate-mapping';
|
|
import { parseCsvDocument } from '../../src/importers/csv-importer';
|
|
import { parseJsonDocument } from '../../src/importers/json-importer';
|
|
import { extractSourceDocument } from '../../src/importers/source-extractor-registry';
|
|
import { genericProfile } from '../../src/profiles/generic/generic-profile';
|
|
import { applyRecognitionOverrides, recognizeImportDocument } from '../../src/recognition/recognition-engine';
|
|
import { sourceMetadata } from './import-source-fixtures';
|
|
|
|
function withExtractor(extraction: SourceExtractionResult, id: string, version: string): SourceExtractionResult {
|
|
return {
|
|
...extraction,
|
|
extractor: { kind: 'source-extractor', id, version },
|
|
processing: { ...extraction.processing, processor: { kind: 'source-extractor', id, version } }
|
|
};
|
|
}
|
|
|
|
describe('finding candidate mapping', () => {
|
|
it('maps neutral fields with the versioned profile and preserves source provenance', () => {
|
|
const text = 'Rule ID;Severity;Title\nSYN-1;High;Synthetic finding';
|
|
const document = parseCsvDocument(sourceMetadata('synthetic.csv', text.length, 'text/csv'), text);
|
|
const extraction = extractSourceDocument(document);
|
|
const mapping = mapFindingCandidates(extraction, genericProfile);
|
|
|
|
expect(mapping).toMatchObject({
|
|
version: 1,
|
|
extractor: extraction.extractor,
|
|
mappingProfile: { kind: 'mapping-profile', id: 'generic', version: '1.0.0' },
|
|
processing: { stage: 'mapping', status: 'complete' }
|
|
});
|
|
expect(mapping.fieldDecisions.map(item => [item.sourceLabel, item.appliedRole])).toEqual([
|
|
['Rule ID', 'rule-id'], ['Severity', 'severity'], ['Title', 'title']
|
|
]);
|
|
expect(mapping.candidates[0].fields[0]).toMatchObject({
|
|
originalValue: 'SYN-1', decodedValue: 'SYN-1', role: 'rule-id',
|
|
source: { fileName: 'synthetic.csv', tableIndex: 0, rowIndex: 1, columnIndex: 0 }
|
|
});
|
|
});
|
|
|
|
it('keeps extractor variants independent from a shared mapping profile', () => {
|
|
const text = 'Title;Severity\nSynthetic finding;Medium';
|
|
const document = parseCsvDocument(sourceMetadata('synthetic.csv', text.length, 'text/csv'), text);
|
|
const extraction = extractSourceDocument(document);
|
|
const first = mapFindingCandidates(withExtractor(extraction, 'variant-a', '1.0.0'), genericProfile);
|
|
const second = mapFindingCandidates(withExtractor(extraction, 'variant-b', '2.0.0'), genericProfile);
|
|
|
|
expect(first.extractor).not.toEqual(second.extractor);
|
|
expect(first.mappingProfile).toEqual(second.mappingProfile);
|
|
expect(first.fieldDecisions).toEqual(second.fieldDecisions);
|
|
expect(first.candidates).toEqual(second.candidates);
|
|
});
|
|
|
|
it('uses the same field-role evidence as document recognition', () => {
|
|
const text = 'Rule ID;Severity;Title\nSYN-1;High;Synthetic finding';
|
|
const document = parseCsvDocument(sourceMetadata('synthetic.csv', text.length, 'text/csv'), text);
|
|
const recognition = recognizeImportDocument(document);
|
|
const mapping = mapFindingCandidates(extractSourceDocument(document), genericProfile);
|
|
|
|
expect(mapping.fieldDecisions.map(item => item.appliedRole)).toEqual(recognition.fields.map(item => item.appliedRole));
|
|
expect(mapping.fieldDecisions.map(item => item.confidence)).toEqual(recognition.fields.map(item => item.confidence));
|
|
});
|
|
|
|
it('carries an explicitly confirmed field role into candidate mapping', () => {
|
|
const text = 'Name;Severity\nSynthetic system;High';
|
|
const document = parseCsvDocument(sourceMetadata('synthetic.csv', text.length, 'text/csv'), text);
|
|
const recognition = recognizeImportDocument(document);
|
|
const confirmed = applyRecognitionOverrides(recognition, {
|
|
fieldRoles: [{ tableId: recognition.fields[0].tableId, columnIndex: 0, role: 'asset' }]
|
|
});
|
|
const mapping = mapFindingCandidates(extractSourceDocument(document), genericProfile, confirmed);
|
|
|
|
expect(mapping.fieldDecisions[0]).toMatchObject({
|
|
appliedRole: 'asset', decisionSource: 'user-confirmed', requiresConfirmation: false
|
|
});
|
|
expect(mapping.candidates[0].fields[0].role).toBe('asset');
|
|
});
|
|
|
|
it('applies explicit native candidate-field confirmation without changing tabular decisions', () => {
|
|
const text = JSON.stringify([{ id: 'SYN-1', detail: 'Synthetic detail' }]);
|
|
const document = parseJsonDocument(sourceMetadata('synthetic.json', text.length, 'application/json'), text);
|
|
const extraction = extractSourceDocument(document);
|
|
const initial = mapFindingCandidates(extraction, genericProfile);
|
|
const confirmed = mapFindingCandidates(extraction, genericProfile, undefined, {
|
|
candidateFieldRoles: [{ decisionId: initial.fieldDecisions[0].id, role: 'title' }]
|
|
});
|
|
|
|
expect(confirmed.fieldDecisions[0]).toMatchObject({
|
|
appliedRole: 'title', decisionSource: 'user-confirmed', requiresConfirmation: false
|
|
});
|
|
expect(confirmed.candidates[0].fields[0].role).toBe('title');
|
|
});
|
|
|
|
it('retains nested native JSON originals while adding roles', () => {
|
|
const text = JSON.stringify([{ id: 'SYN-1', details: { enabled: true, score: 7 } }]);
|
|
const document = parseJsonDocument(sourceMetadata('synthetic.json', text.length, 'application/json'), text);
|
|
const mapping = mapFindingCandidates(extractSourceDocument(document), genericProfile);
|
|
|
|
expect(mapping.candidates[0].fields.find(field => field.key === 'details')).toMatchObject({
|
|
originalValue: { enabled: true, score: 7 }, source: { objectPath: '$[0].details' }
|
|
});
|
|
});
|
|
});
|