122 lines
6.2 KiB
TypeScript
122 lines
6.2 KiB
TypeScript
import { useApp } from '../../app/AppProvider';
|
|
import { useEnginePreferences } from '../../app/engine-preferences';
|
|
import type { FindingRecord } from '../../domain/finding';
|
|
import { gpoRecordSettingKey } from '../../comparison/gpo-record-key';
|
|
import type { DynamicFacet } from '../search-filter/dynamic-facets';
|
|
import { GPO_SOURCE_ID, type TimeslotChange, type TimeslotChangeEntry, type TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report';
|
|
import { usePublishedComparison, type PublishedComparison } from './use-timeslot-comparison';
|
|
|
|
/** Work facet of the Comparison Engine; present only while a comparison of the open timeslot B is loaded. */
|
|
export const COMPARISON_CHANGE_FACET_ID = 'comparison:change';
|
|
const SOURCE_SCAN_FACET_ID = 'provenance:source-scan';
|
|
|
|
/** Facet order: better first, then worse, then neutral and unclear states. */
|
|
export const CHANGE_LABELS: Array<{ change: TimeslotChange; label: string }> = [
|
|
{ change: 'resolved', label: 'Resolved' },
|
|
{ change: 'new', label: 'New' },
|
|
{ change: 'regressed', label: 'Regressed' },
|
|
{ change: 'value-changed', label: 'Value changed' },
|
|
{ change: 'unchanged', label: 'Unchanged' },
|
|
{ change: 'unverified', label: 'Unverified, still open' },
|
|
{ change: 'not-applicable', label: 'Not applicable' },
|
|
{ change: 'not-comparable', label: 'Not comparable' }
|
|
];
|
|
|
|
export interface ComparisonWorkFacetIndex {
|
|
facets: DynamicFacet[];
|
|
/** The change of a Work record of timeslot B, if the comparison names one. */
|
|
changeOf(record: FindingRecord): TimeslotChange | undefined;
|
|
/** The full comparison entry behind that change, for the Finding Reader. */
|
|
entryOf(record: FindingRecord): TimeslotChangeEntry | undefined;
|
|
comparison: PublishedComparison;
|
|
}
|
|
|
|
|
|
/**
|
|
* Maps the report onto Work records of timeslot B: scanner records by occurrence (a current record, or the
|
|
* B counterpart of an A deviation), GPO settings by scope and technical identity, like Result DB drill-downs.
|
|
* A deviation of A without a B record has no Work row in B and is therefore not offered.
|
|
*/
|
|
export function entryIndex(report: TimeslotComparisonReport) {
|
|
const byOccurrence = new Map<string, TimeslotChangeEntry>();
|
|
const bySetting = new Map<string, TimeslotChangeEntry>();
|
|
for (const entry of report.changes) {
|
|
if (entry.sourceId === GPO_SOURCE_ID) {
|
|
if (entry.identityKey && (entry.side === 'target' || entry.counterpartIds.length)) bySetting.set(entry.identityKey, entry);
|
|
continue;
|
|
}
|
|
for (const id of entry.side === 'target' ? entry.recordIds : entry.counterpartIds) byOccurrence.set(id, entry);
|
|
}
|
|
return (record: FindingRecord): TimeslotChangeEntry | undefined => {
|
|
const scanner = record.findingOccurrenceId ? byOccurrence.get(record.findingOccurrenceId) : undefined;
|
|
if (scanner) return scanner;
|
|
const key = gpoRecordSettingKey(record);
|
|
return key ? bySetting.get(key) : undefined;
|
|
};
|
|
}
|
|
|
|
/** Changes that have at least one Work record in timeslot B, so the Compare tab can open them in Work. */
|
|
export function changesWithWorkRecords(report: TimeslotComparisonReport): Set<TimeslotChange> {
|
|
return new Set(report.changes.filter(entry => entry.side === 'target' || entry.counterpartIds.length).map(entry => entry.change));
|
|
}
|
|
|
|
const indexes = new WeakMap<PublishedComparison, ComparisonWorkFacetIndex>();
|
|
export function comparisonWorkFacetIndex(comparison: PublishedComparison): ComparisonWorkFacetIndex {
|
|
const existing = indexes.get(comparison);
|
|
if (existing) return existing;
|
|
const entryOf = entryIndex(comparison.report);
|
|
const changeOf = (record: FindingRecord) => entryOf(record)?.change;
|
|
const date = new Date(comparison.baseline.date);
|
|
const label = Number.isNaN(date.getTime()) ? comparison.baseline.date
|
|
: date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
const index: ComparisonWorkFacetIndex = {
|
|
comparison,
|
|
changeOf,
|
|
entryOf,
|
|
facets: [{
|
|
id: COMPARISON_CHANGE_FACET_ID,
|
|
label: 'Change',
|
|
source: 'derived',
|
|
kind: 'category',
|
|
description: `Comparison Engine: change of each finding and GPO setting against timeslot A (${label}). It does not change results.`,
|
|
values: CHANGE_LABELS.map(item => ({ value: item.change, label: item.label, count: 0 })),
|
|
valuesFor: record => { const change = changeOf(record); return change ? [change] : []; }
|
|
}]
|
|
};
|
|
indexes.set(comparison, index);
|
|
return index;
|
|
}
|
|
|
|
/** Adds the `Change` facet directly before the permanent final `Source: Scan` control. */
|
|
export function withComparisonFacets(facets: DynamicFacet[], index: ComparisonWorkFacetIndex | undefined): DynamicFacet[] {
|
|
if (!index) return facets;
|
|
const sourceScan = facets.findIndex(facet => facet.id === SOURCE_SCAN_FACET_ID);
|
|
return sourceScan < 0 ? [...facets, ...index.facets]
|
|
: [...facets.slice(0, sourceScan), ...index.facets, ...facets.slice(sourceScan)];
|
|
}
|
|
|
|
/** The facet index for Work, or undefined while the engine is off or no comparison of the open timeslot is loaded. */
|
|
export function useComparisonWorkFacets(): ComparisonWorkFacetIndex | undefined {
|
|
const app = useApp();
|
|
const enabled = useEnginePreferences().comparison;
|
|
const comparison = usePublishedComparison(app.targetProjectId, app.activeAssessmentIds ?? []);
|
|
return enabled && comparison ? comparisonWorkFacetIndex(comparison) : undefined;
|
|
}
|
|
|
|
/**
|
|
* Work record IDs of one change in the timeslot that is open: scanner records by occurrence (the A records of
|
|
* a baseline entry, or the B records and counterparts), GPO settings by scope and technical identity.
|
|
*/
|
|
export function workRecordIdsFor(entry: TimeslotChangeEntry, timeslot: 'baseline' | 'target', records: FindingRecord[]): string[] {
|
|
if (entry.sourceId === GPO_SOURCE_ID) {
|
|
if (!entry.identityKey) return [];
|
|
return records.filter(record => {
|
|
return gpoRecordSettingKey(record) === entry.identityKey;
|
|
}).map(record => record.id);
|
|
}
|
|
const occurrences = new Set(timeslot === 'baseline'
|
|
? (entry.side === 'baseline' ? entry.recordIds : entry.counterpartIds)
|
|
: (entry.side === 'target' ? entry.recordIds : entry.counterpartIds));
|
|
return records.filter(record => record.findingOccurrenceId && occurrences.has(record.findingOccurrenceId)).map(record => record.id);
|
|
}
|