feat: make hanging protocol work on displaySets instead of series (#2837)

* feat(HP): Apply HP to display sets, fix race condition

* fix: displaySetService no event not needed (#2912)

* fix various styles and renamings

* feat: refactored hp service

* fix: use HP service event for viewport grid

* remove unnecessary doc

* fix test

* apply review comment

* fix: segmentation creation

Co-authored-by: Alireza <ar.sedghi@gmail.com>
This commit is contained in:
Bill WallaceandAlireza authored and GitHub committed 2022-09-07 11:59:11 -04:00
1 parent 7c0756824e
commit e1d366e1e1
26 files changed
+893 -979

No files matched your search

@@ -211,7 +211,14 @@ const BaseImplementation = {
let study = _getStudy(StudyInstanceUID);
if (!study) {
study = createStudyMetadata(StudyInstanceUID);
// Will typically be undefined with a compliant DICOMweb server, reset later
study.StudyDescription = seriesSummaryMetadata[0].StudyDescription;
seriesSummaryMetadata.forEach(item => {
if (study.ModalitiesInStudy.indexOf(item.Modality) === -1) {
study.ModalitiesInStudy.push(item.Modality);
}
});
study.NumberOfStudyRelatedSeries = seriesSummaryMetadata.length;
_model.studies.push(study);
}
@@ -0,0 +1,7 @@
/** Defines a typescript type for study metadata */
interface StudyMetadata {
readonly StudyInstanceUID: string;
StudyDescription?: string;
}
export default StudyMetadata;
@@ -4,6 +4,7 @@ function createStudyMetadata(StudyInstanceUID) {
return {
StudyInstanceUID,
StudyDescription: '',
ModalitiesInStudy: [],
isLoaded: false,
series: [],
/**
@@ -13,6 +14,9 @@ function createStudyMetadata(StudyInstanceUID) {
*/
addInstanceToSeries: function(instance) {
const { SeriesInstanceUID } = instance;
if (!this.StudyDescription) {
this.StudyDescription = instance.StudyDescription;
}
const existingSeries = this.series.find(
s => s.SeriesInstanceUID === SeriesInstanceUID
);
@@ -22,16 +26,24 @@ function createStudyMetadata(StudyInstanceUID) {
} else {
const series = createSeriesMetadata([instance]);
this.series.push(series);
const { Modality } = series;
if (this.ModalitiesInStudy.indexof(Modality) === -1) {
this.ModalitiesInStudy.push(Modality);
}
}
},
/**
*
* @param {object[]} instances
* @param {string} instances[].SeriesInstanceUID
* @param {string} instances[].StudyDescription
* @returns {bool} true if series were added; false if series already exist
*/
addInstancesToSeries: function(instances) {
const { SeriesInstanceUID } = instances[0];
if (!this.StudyDescription) {
this.StudyDescription = instances[0].StudyDescription;
}
const existingSeries = this.series.find(
s => s.SeriesInstanceUID === SeriesInstanceUID
);
@@ -0,0 +1,7 @@
interface IDisplaySet {
displaySetInstanceUID: string;
StudyInstanceUID: string;
SeriesInstanceUID?: string;
}
export default IDisplaySet;
@@ -4,10 +4,18 @@ import validate from './lib/validator';
* Match a Metadata instance against rules using Validate.js for validation.
* @param {InstanceMetadata} metadataInstance Metadata instance object
* @param {Array} rules Array of MatchingRules instances (StudyMatchingRule|SeriesMatchingRule|ImageMatchingRule) for the match
* @param {object} options is an object containing additional information
* @param {object[]} options.studies is a list of all the studies
* @param {object[]} options.displaySets is a list of the display sets
* @return {Object} Matching Object with score and details (which rule passed or failed)
*/
const match = (metadataInstance, rules, customAttributeRetrievalCallbacks) => {
const options = {
const match = (
metadataInstance,
rules,
customAttributeRetrievalCallbacks,
options
) => {
const validateOptions = {
format: 'grouped',
};
@@ -16,18 +24,32 @@ const match = (metadataInstance, rules, customAttributeRetrievalCallbacks) => {
failed: [],
};
const readValues = {};
let requiredFailed = false;
let score = 0;
rules.forEach(rule => {
const attribute = rule.attribute;
const { attribute } = rule;
// Do not use the custom attribute from the metadataInstance since it is subject to change
if (customAttributeRetrievalCallbacks.hasOwnProperty(attribute)) {
const customAttribute = customAttributeRetrievalCallbacks[attribute];
metadataInstance[attribute] = customAttribute.callback(metadataInstance);
readValues[attribute] = customAttributeRetrievalCallbacks[
attribute
].callback(metadataInstance, options);
} else {
readValues[attribute] =
metadataInstance[attribute] ??
((metadataInstance.images || metadataInstance.others || [])[0] || {})[
attribute
];
}
console.log(
'Test',
attribute,
readValues[attribute],
JSON.stringify(rule.constraint)
);
// Format the constraint as required by Validate.js
const testConstraint = {
[attribute]: rule.constraint,
@@ -35,13 +57,7 @@ const match = (metadataInstance, rules, customAttributeRetrievalCallbacks) => {
// Create a single attribute object to be validated, since metadataInstance is an
// instance of Metadata (StudyMetadata, SeriesMetadata or InstanceMetadata)
let attributeValue = metadataInstance[attribute];
if (attributeValue === undefined) {
if (attribute === 'NumberOfStudyRelatedSeries') {
attributeValue = metadataInstance.series?.length;
}
// Add other computable values such as modalities in study
}
let attributeValue = readValues[attribute];
const attributeMap = {
[attribute]: attributeValue,
};
@@ -49,7 +65,7 @@ const match = (metadataInstance, rules, customAttributeRetrievalCallbacks) => {
// Use Validate.js to evaluate the constraints on the specified metadataInstance
let errorMessages;
try {
errorMessages = validate(attributeMap, testConstraint, [options]);
errorMessages = validate(attributeMap, testConstraint, [validateOptions]);
} catch (e) {
errorMessages = ['Something went wrong during validation.', e];
}
@@ -58,8 +74,7 @@ const match = (metadataInstance, rules, customAttributeRetrievalCallbacks) => {
// If no errorMessages were returned, then validation passed.
// Add the rule's weight to the total score
score += parseInt(rule.weight, 10);
score += parseInt(rule.weight || 1, 10);
// Log that this rule passed in the matching details object
details.passed.push({
rule,
@@ -0,0 +1,166 @@
import HangingProtocolServiceClass from './HangingProtocolService';
const testProtocol = {
id: 'test',
locked: true,
hasUpdatedPriorsInformation: false,
name: 'Default',
createdDate: '2021-02-23T19:22:08.894Z',
modifiedDate: '2021-02-23T19:22:08.894Z',
availableTo: {},
editableBy: {},
toolGroupIds: ['ctToolGroup', 'ptToolGroup'],
imageLoadStrategy: 'interleaveTopToBottom', // "default" , "interleaveTopToBottom", "interleaveCenter"
protocolMatchingRules: [
{
id: 'wauZK2QNEfDPwcAQo',
weight: 1,
attribute: 'StudyDescription',
constraint: {
contains: {
value: 'PETCT',
},
},
required: false,
},
],
stages: [
{
id: 'hYbmMy3b7pz7GLiaT',
name: 'default',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 1,
},
},
displaySets: [
{
id: 'displaySet',
seriesMatchingRules: [
{
weight: 1,
attribute: 'Modality',
constraint: {
equals: 'CT',
},
required: true,
},
{
weight: 1,
attribute: 'numImageFrames',
constraint: {
greaterThan: 10,
},
},
],
studyMatchingRules: [],
},
],
viewports: [
{
viewportOptions: {
viewportId: 'ctAXIAL',
viewportType: 'volume',
orientation: 'axial',
toolGroupId: 'ctToolGroup',
customViewportOptions: {
initialScale: 2.5,
},
initialImageOptions: {
// index: 5,
preset: 'first', // 'first', 'last', 'middle'
},
syncGroups: [
{
type: 'cameraPosition',
id: 'axialSync',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'displaySet',
},
],
},
],
},
],
numberOfPriorsReferenced: -1,
};
const studyMatch = {
StudyInstanceUID: 'studyMatch',
StudyDescription: 'A PETCT study type',
};
const displaySet1 = {
...studyMatch,
SeriesInstanceUID: 'ds1',
displaySetInstanceUID: 'displaySet1',
numImageFrames: 11,
Modality: 'CT',
};
const displaySet2 = {
...displaySet1,
SeriesInstanceUID: 'ds2',
displaySetInstanceUID: 'displaySet2',
Modality: 'PT',
};
const displaySet3 = {
...displaySet1,
numImageFrames: 3,
displaySetInstanceUID: 'displaySet3',
};
const studyMatchDisplaySets = [displaySet3, displaySet2, displaySet1];
describe('HangingProtocolService', () => {
const commandsManager = {};
const hps = new HangingProtocolServiceClass(commandsManager);
let initialScaling;
beforeAll(() => {
hps.addProtocols([testProtocol]);
});
it('has one protocol', () => {
expect(hps.getProtocols().length).toBe(1);
});
describe('run', () => {
it('matches best image match', () => {
hps.run({ studies: [studyMatch], displaySets: studyMatchDisplaySets });
const {
hpAlreadyApplied,
viewportMatchDetails,
displaySetMatchDetails,
} = hps.getMatchDetails();
expect(hpAlreadyApplied).toMatchObject([false]);
expect(viewportMatchDetails.length).toBe(1);
expect(viewportMatchDetails[0]).toMatchObject({
viewportOptions: {
viewportId: 'ctAXIAL',
viewportType: 'volume',
orientation: 'axial',
toolGroupId: 'ctToolGroup',
},
// Matches ds1 because it matches 2 rules, a required and an optional
// ds2 fails to match required and ds3 fails to match an optional.
displaySetsInfo: [
{
SeriesInstanceUID: 'ds1',
displaySetInstanceUID: 'displaySet1',
displaySetOptions: {},
},
],
});
});
});
});
@@ -1,53 +1,110 @@
import pubSubServiceInterface from '../_shared/pubSubServiceInterface';
import sortBy from '../../utils/sortBy.js';
import sortBy from '../../utils/sortBy';
import ProtocolEngine from './ProtocolEngine';
import StudyMetadata from '../DicomMetadataStore/StudyMetadata';
import IDisplaySet from '../DisplaySetService/IDisplaySet';
const EVENTS = {
STAGE_CHANGE: 'event::hanging_protocol_stage_change',
PROTOCOL_CHANGED: 'event::hanging_protocol_changed',
NEW_LAYOUT: 'event::hanging_protocol_new_layout',
CUSTOM_IMAGE_LOAD_PERFORMED:
'event::hanging_protocol_custom_image_load_performed',
};
type ViewportOptions = {
orientation: string;
toolGroupId: string;
viewportId: string;
viewportType: string;
initialImageOptions: Record<string, unknown>;
syncGroups: Record<string, unknown>;
};
type ViewportMatchDetails = {
viewportOptions: ViewportOptions;
displaySetsInfo: {
SeriesInstanceUID: string;
displaySetInstanceUID: string;
displaySetOptions: Record<string, unknown>;
};
};
type DisplaySetMatchDetails = {
SeriesInstanceUID: string;
StudyInstanceUID: string;
displaySetInstanceUID: string;
matchDetails: any;
matchingScore: number;
sortingInfo: any;
};
type HangingProtocolMatchDetails = {
displaySetMatchDetails: Map<string, DisplaySetMatchDetails>;
viewportMatchDetails: ViewportMatchDetails[];
hpAlreadyApplied: boolean[];
};
class HangingProtocolService {
studies: StudyMetadata[];
protocols: Record<string, unknown>[];
protocol: Record<string, unknown>;
stage: number;
_commandsManager: Record<string, unknown>;
protocolEngine: ProtocolEngine;
hpAlreadyApplied: boolean[] = [];
customViewportSettings = [];
displaySets: IDisplaySet[] = [];
activeStudy: Record<string, unknown>;
debugLogging: false;
customAttributeRetrievalCallbacks = {
NumberOfStudyRelatedSeries: {
name: 'The number of series in the study',
callback: metadata =>
metadata.NumberOfStudyRelatedSeries ?? metadata.series?.length,
},
NumberOfSeriesRelatedInstances: {
name: 'The number of instances in the display set',
callback: metadata => metadata.numImageFrames,
},
ModalitiesInStudy: {
name: 'Gets the array of the modalities for the series',
callback: metadata =>
metadata.ModalitiesInStudy ??
(metadata.series || []).reduce((prev, curr) => {
const { Modality } = curr;
if (Modality && prev.indexOf(Modality) == -1) prev.push(Modality);
return prev;
}, []),
},
};
listeners = {};
registeredImageLoadStrategies = {};
activeImageLoadStrategyName = null;
customImageLoadPerformed = false;
/**
* displaySetMatchDetails = <displaySetId, match>
* DisplaySetId is the id defined in the hangingProtocol object itself
* and match is an object that contains information about
*/
displaySetMatchDetails: Map<string, DisplaySetMatchDetails> = new Map();
/**
* An array that contains for each viewport (viewportIndex) specified in the
* hanging protocol, an object of the form
*/
viewportMatchDetails = [] as ViewportMatchDetails[];
constructor(commandsManager) {
this._commandsManager = commandsManager;
this.protocols = [];
this.ProtocolEngine = undefined;
this.protocolEngine = undefined;
this.protocol = undefined;
this.stage = undefined;
/**
* An array that contains for each viewport (viewportIndex) specified in the
* hanging protocol, an object of the form
*
* {
* viewportOptions,
* displaySetsInfo, // contains array of [ { SeriesInstanceUID, displaySetOPtions}, ... ]
* }
*/
this.matchDetails = [];
/**
* displaySetMatchDetails = <displaySetId, match>
* DisplaySetId is the id defined in the hangingProtocol
* match is an object that contains information about
*
* {
* SeriesInstanceUID,
* StudyInstanceUID,
* matchDetails,
* matchingScore,
* sortingInfo
* }
*/
this.displaySetMatchDetails = new Map();
this.hpAlreadyApplied = [];
this.studies = [];
this.customViewportSettings = [];
this.customAttributeRetrievalCallbacks = {};
this.listeners = {};
this.registeredImageLoadStrategies = {};
this.activeImageLoadStrategyName = null;
this.customImageLoadPerformed = false;
Object.defineProperty(this, 'EVENTS', {
value: EVENTS,
writable: false,
@@ -57,27 +114,27 @@ class HangingProtocolService {
Object.assign(this, pubSubServiceInterface);
}
reset() {
public reset() {
this.studies = [];
this.protocols = [];
this.hpAlreadyApplied = [];
this.matchDetails = [];
this.viewportMatchDetails = [];
// this.ProtocolEngine.reset()
}
getDisplaySetsMatchDetails() {
return this.displaySetMatchDetails;
public getMatchDetails(): HangingProtocolMatchDetails {
return {
viewportMatchDetails: this.viewportMatchDetails,
displaySetMatchDetails: this.displaySetMatchDetails,
hpAlreadyApplied: this.hpAlreadyApplied,
};
}
getState() {
return [this.matchDetails, this.hpAlreadyApplied];
}
getProtocols() {
public getProtocols() {
return this.protocols;
}
addProtocols(protocols) {
public addProtocols(protocols) {
protocols.forEach(protocol => {
if (this.protocols.indexOf(protocol) === -1) {
this.protocols.push(this._validateProtocol(protocol));
@@ -85,21 +142,37 @@ class HangingProtocolService {
});
}
run(studyMetaData, protocol) {
if (!this.studies.includes(studyMetaData)) {
this.studies.push(studyMetaData);
}
// copy here so we don't mutate it
const metaData = Object.assign({}, studyMetaData);
/**
* Run the hanging protocol decisions tree on the active study,
* studies list and display sets, firing a hanging protocol event when
* complete to indicate the hanging protocol is ready.
*
* @param params is the dataset to run the hanging protocol on.
* @param params.activeStudy is the "primary" study to hang This may or may
* not be displayed by the actual viewports.
* @param params.studies is the list of studies to hang
* @param params.displaySets is the list of display sets associated with
* the studies to display in viewports.
* @param protocol is a specific protocol to apply.
* @returns
*/
public run({ studies, displaySets, activeStudy }, protocol) {
this.studies = [...studies];
this.displaySets = displaySets;
this.activeStudy = activeStudy || studies[0];
this.ProtocolEngine = new ProtocolEngine(
this.protocolEngine = new ProtocolEngine(
this.protocols,
this.customAttributeRetrievalCallbacks
);
// if there is no pre-defined protocol
if (!protocol || protocol.id === undefined) {
const matchedProtocol = this.ProtocolEngine.run(metaData);
const matchedProtocol = this.protocolEngine.run({
studies: this.studies,
activeStudy,
displaySets,
});
this._setProtocol(matchedProtocol);
return;
}
@@ -112,7 +185,7 @@ class HangingProtocolService {
* and its callback has been added to the HangingProtocolService
* @returns {boolean} true
*/
hasCustomImageLoadStrategy() {
public hasCustomImageLoadStrategy() {
return (
this.activeImageLoadStrategyName !== null &&
this.registeredImageLoadStrategies[
@@ -121,7 +194,7 @@ class HangingProtocolService {
);
}
getCustomImageLoadPerformed() {
public getCustomImageLoadPerformed() {
return this.customImageLoadPerformed;
}
@@ -130,13 +203,13 @@ class HangingProtocolService {
* @param {string} name strategy name
* @param {Function} callback image loader callback
*/
registerImageLoadStrategy(name, callback) {
public registerImageLoadStrategy(name, callback) {
if (callback instanceof Function && name) {
this.registeredImageLoadStrategies[name] = callback;
}
}
setHangingProtocolAppliedForViewport(i) {
public setHangingProtocolAppliedForViewport(i) {
this.hpAlreadyApplied[i] = true;
}
@@ -147,18 +220,21 @@ class HangingProtocolService {
* @param attributeId The ID used to refer to the attribute (e.g. 'timepointType')
* @param attributeName The name of the attribute to be displayed (e.g. 'Timepoint Type')
* @param callback The function used to calculate the attribute value from the other attributes at its level (e.g. study/series/image)
* @param options to add to the "this" object for the custom attribute retriever
*/
addCustomAttribute(attributeId, attributeName, callback) {
public addCustomAttribute(attributeId, attributeName, callback, options) {
this.customAttributeRetrievalCallbacks[attributeId] = {
...options,
id: attributeId,
name: attributeName,
callback: callback,
callback,
};
}
/**
* Switches to the next protocol stage in the display set sequence
*/
nextProtocolStage() {
public nextProtocolStage() {
console.log('ProtocolEngine::nextProtocolStage');
if (!this._setCurrentProtocolStage(1)) {
@@ -169,7 +245,7 @@ class HangingProtocolService {
/**
* Switches to the previous protocol stage in the display set sequence
*/
previousProtocolStage() {
public previousProtocolStage() {
console.log('ProtocolEngine::previousProtocolStage');
if (!this._setCurrentProtocolStage(-1)) {
@@ -187,8 +263,8 @@ class HangingProtocolService {
];
const loadedData = loader({
data,
displaySetsMatchDetails: this.getDisplaySetsMatchDetails(),
matchDetails: this.matchDetails,
displaySetsMatchDetails: this.displaySetMatchDetails,
viewportMatchDetails: this.viewportMatchDetails,
});
// if loader successfully re-arranged the data with the custom strategy
@@ -253,6 +329,12 @@ class HangingProtocolService {
}
}
this._updateViewports();
this._broadcastChange(this.EVENTS.PROTOCOL_CHANGED, {
viewportMatchDetails: this.viewportMatchDetails,
displaySetMatchDetails: this.displaySetMatchDetails,
hpAlreadyApplied: this.hpAlreadyApplied,
});
}
/**
@@ -280,9 +362,13 @@ class HangingProtocolService {
return this.protocol.stages[this.stage];
}
/**
* Updates the viewports with the selected protocol stage.
*/
_updateViewports() {
// Make sure we have an active protocol with a non-empty array of display sets
if (!this._getNumProtocolStages()) {
console.log('No protocol stages - nothing to display');
return;
}
@@ -301,6 +387,7 @@ class HangingProtocolService {
!stageModel.displaySets ||
!stageModel.viewports.length
) {
console.log('Stage cannot be applied', stageModel);
return;
}
@@ -311,6 +398,7 @@ class HangingProtocolService {
// If no such layout properties exist, stop here.
const layoutProps = stageModel.viewportStructure.properties;
if (!layoutProps) {
console.log('No viewportStructure.properties in', stageModel);
return;
}
@@ -324,12 +412,13 @@ class HangingProtocolService {
});
// Matching the displaySets
// Note: this is happening before displaySets are created. Here, displaySet
// only contains the information of the id of the displaySet to be matched
// based on some rules
stageModel.displaySets.forEach(displaySet => {
const { bestMatch } = this._matchImages(displaySet);
const { bestMatch, matchingScores } = this._matchImages(displaySet);
this.displaySetMatchDetails.set(displaySet.id, bestMatch);
if (bestMatch) {
bestMatch.matchingScores = matchingScores;
}
});
// Loop through each viewport
@@ -339,30 +428,42 @@ class HangingProtocolService {
// DisplaySets for the viewport, Note: this is not the actual displaySet,
// but it is a info to locate the displaySet from the displaySetService
let displaySetsInfo = [];
viewport.displaySets.forEach(({ id, options: displaySetOptions }) => {
const viewportDisplaySet = this.displaySetMatchDetails.get(id);
const displaySetsInfo = [];
viewport.displaySets.forEach(
({ id, displaySetIndex = 0, options: displaySetOptions }) => {
const viewportDisplaySetMain = this.displaySetMatchDetails.get(id);
// Use the display set index to allow getting the "next" match, eg
// matching all display sets, and get the displaySetIndex'th item
const viewportDisplaySet =
!viewportDisplaySetMain || displaySetIndex === 0
? viewportDisplaySetMain
: viewportDisplaySetMain.matchingScores[displaySetIndex];
if (viewportDisplaySet) {
const { SeriesInstanceUID } = viewportDisplaySet;
if (viewportDisplaySet) {
const {
SeriesInstanceUID,
displaySetInstanceUID,
} = viewportDisplaySet;
const displaySetInfo = {
SeriesInstanceUID,
displaySetOptions,
};
const displaySetInfo = {
SeriesInstanceUID,
displaySetInstanceUID,
displaySetOptions,
};
displaySetsInfo.push(displaySetInfo);
} else {
console.warn(
`
displaySetsInfo.push(displaySetInfo);
} else {
console.warn(
`
The hanging protocol viewport is requesting to display ${id} displaySet that is not
matched based on the provided criteria (e.g. matching rules).
`
);
);
}
}
});
);
this.matchDetails[viewportIndex] = {
this.viewportMatchDetails[viewportIndex] = {
viewportOptions,
displaySetsInfo,
};
@@ -370,50 +471,77 @@ class HangingProtocolService {
}
// Match images given a list of Studies and a Viewport's image matching reqs
_matchImages(displaySet) {
console.log('ProtocolEngine::matchImages');
_matchImages(displaySetRules) {
// TODO: matching is applied on study and series level, instance
// level matching needs to be added in future
// Todo: handle fusion viewports by not taking the first displaySet rule for the viewport
const { studyMatchingRules, seriesMatchingRules } = displaySet;
const {
studyMatchingRules = [],
seriesMatchingRules,
findAll = false,
} = displaySetRules;
const matchingScores = [];
let highestStudyMatchingScore = 0;
let highestSeriesMatchingScore = 0;
console.log(
'ProtocolEngine::matchImages',
studyMatchingRules,
seriesMatchingRules
);
this.studies.forEach(study => {
const studyMatchDetails = this.ProtocolEngine.findMatch(
const studyDisplaySets = this.displaySets.filter(
it => it.StudyInstanceUID === study.StudyInstanceUID
);
const studyMatchDetails = this.protocolEngine.findMatch(
study,
studyMatchingRules
studyMatchingRules,
{ studies: this.studies, displaySets: studyDisplaySets }
);
// Prevent bestMatch from being updated if the matchDetails' required attribute check has failed
if (
studyMatchDetails.requiredFailed === true ||
studyMatchDetails.score < highestStudyMatchingScore
) {
if (studyMatchDetails.requiredFailed === true) {
return;
}
highestStudyMatchingScore = studyMatchDetails.score;
study.series.forEach(aSeries => {
const seriesMatchDetails = this.ProtocolEngine.findMatch(
aSeries,
seriesMatchingRules
this.debug(
'study',
study.StudyInstanceUID,
'display sets #',
this.displaySets.length
);
this.displaySets.forEach(displaySet => {
const {
StudyInstanceUID,
SeriesInstanceUID,
displaySetInstanceUID,
} = displaySet;
if (StudyInstanceUID !== study.StudyInstanceUID) return;
const seriesMatchDetails = this.protocolEngine.findMatch(
displaySet,
seriesMatchingRules,
{ studies: this.studies, instance: displaySet.images?.[0] }
);
// Prevent bestMatch from being updated if the matchDetails' required attribute check has failed
if (
seriesMatchDetails.requiredFailed === true ||
seriesMatchDetails.score < highestSeriesMatchingScore
) {
if (seriesMatchDetails.requiredFailed === true) {
this.debug(
'Display set required failed',
displaySet,
seriesMatchingRules
);
return;
}
highestSeriesMatchingScore = seriesMatchDetails.score;
this.debug('Found displaySet for rules', displaySet);
highestSeriesMatchingScore = Math.max(
seriesMatchDetails.score,
highestSeriesMatchingScore
);
const matchDetails = {
passed: [],
@@ -438,21 +566,27 @@ class HangingProtocolService {
seriesMatchDetails.score + studyMatchDetails.score;
const imageDetails = {
StudyInstanceUID: study.StudyInstanceUID,
SeriesInstanceUID: aSeries.SeriesInstanceUID,
StudyInstanceUID,
SeriesInstanceUID,
displaySetInstanceUID,
matchingScore: totalMatchScore,
matchDetails: matchDetails,
sortingInfo: {
score: totalMatchScore,
study: study.StudyInstanceUID,
series: parseInt(aSeries.SeriesNumber),
series: parseInt(displaySet.SeriesNumber),
},
};
this.debug('Adding display set', displaySet, imageDetails);
matchingScores.push(imageDetails);
});
});
if (matchingScores.length === 0) {
console.log('No match found');
}
// Sort the matchingScores
const sortingFunction = sortBy(
{
@@ -473,7 +607,11 @@ class HangingProtocolService {
const bestMatch = matchingScores[0];
console.log('ProtocolEngine::matchImages bestMatch', bestMatch);
console.log(
'ProtocolEngine::matchImages bestMatch',
bestMatch,
matchingScores
);
return {
bestMatch,
@@ -495,7 +633,7 @@ class HangingProtocolService {
* Check if the previous stage is available
* @return {Boolean} True if previous stage is available or false otherwise
*/
_isPreviousStageAvailable() {
_isPreviousStageAvailable(): boolean {
return this.stage - 1 >= 0;
}
@@ -506,7 +644,7 @@ class HangingProtocolService {
* @param {Integer} stageAction An integer value specifying wheater next (1) or previous (-1) stage
* @return {Boolean} True if new stage has set or false, otherwise
*/
_setCurrentProtocolStage(stageAction) {
_setCurrentProtocolStage(stageAction): boolean {
//reseting the applied protocols
this.hpAlreadyApplied = [];
// Check if previous or next stage is available
@@ -520,9 +658,7 @@ class HangingProtocolService {
this.stage += stageAction;
// Log the new stage
console.log(
`ProtocolEngine::setCurrentProtocolStage stage = ${this.stage}`
);
this.debug(`ProtocolEngine::setCurrentProtocolStage stage = ${this.stage}`);
// Since stage has changed, we need to update the viewports
// and redo matchings
@@ -530,11 +666,22 @@ class HangingProtocolService {
// Everything went well
this._broadcastChange(this.EVENTS.STAGE_CHANGE, {
matchDetails: this.matchDetails,
viewportMatchDetails: this.viewportMatchDetails,
hpAlreadyApplied: this.hpAlreadyApplied,
displaySetMatchDetails: this.displaySetMatchDetails,
});
return true;
}
/** Set this.debugLogging to true to show debug level logging - needed
* to be able to figure out why hanging protocols are or are not applying.
*/
debug(...args): void {
if (this.debugLogging) {
console.log(...args);
}
}
/**
* Broadcasts hanging protocols changes.
*
@@ -10,8 +10,15 @@ export default class ProtocolEngine {
this.study = undefined;
}
run(studyMetaData) {
this.study = studyMetaData;
/** Evaluate the hanging protocol matches on the given:
* @param props.studies is a list of studies to compare against (for priors evaluation)
* @param props.activeStudy is the current metadata for the study to display.
* @param props.displaySets are the list of display sets which can be modified.
*/
run({ studies, displaySets, activeStudy }) {
this.studies = studies;
this.study = activeStudy || studies[0];
this.displaySets = displaySets;
return this.getBestProtocolMatch();
}
@@ -49,9 +56,11 @@ export default class ProtocolEngine {
// Clear all data currently in matchedProtocols
this._clearMatchedProtocols();
// TODO: handle more than one study
const study = this.study;
const matched = this.findMatchByStudy(study);
// TODO: handle more than one study - this.studies has the list of studies
const matched = this.findMatchByStudy(this.study, {
studies: this.studies,
displaySets: this.displaySets,
});
// For each matched protocol, check if it is already in MatchedProtocols
matched.forEach(matchedDetail => {
@@ -72,11 +81,12 @@ export default class ProtocolEngine {
});
}
findMatch(metaData, rules) {
findMatch(metaData, rules, options) {
return HPMatcher.match(
metaData,
rules,
this.customAttributeRetrievalCallbacks
this.customAttributeRetrievalCallbacks,
options
);
}
@@ -84,11 +94,12 @@ export default class ProtocolEngine {
* Finds the best protocols from Protocol Store, matching each protocol matching rules
* with the given study. The best protocol are ordered by score and returned in an array
* @param {Object} study StudyMetadata instance object
* @param {object} options containing additional matching data.
* @return {Array} Array of match objects or an empty array if no match was found
* Each match object has the score of the matching and the matched
* protocol
*/
findMatchByStudy(study) {
findMatchByStudy(study, options) {
const matched = [];
this.protocols.forEach(protocol => {
@@ -98,13 +109,14 @@ export default class ProtocolEngine {
let rules = protocol.protocolMatchingRules.slice();
if (!rules || !rules.length) {
console.warn(
'ProtocolEngine::findMatchByStudy no matching rules - specify protocolMatchingRules'
'ProtocolEngine::findMatchByStudy no matching rules - specify protocolMatchingRules',
protocol.id
);
return;
}
// Run the matcher and get matching details
const matchedDetails = this.findMatch(study, rules);
const matchedDetails = this.findMatch(study, rules, options);
const score = matchedDetails.score;
// The protocol matched some rule, add it to the matched list
@@ -156,605 +168,3 @@ export default class ProtocolEngine {
return this.matchedProtocols.get(highestScoringProtocolId);
}
}
/**
* Resets the ProtocolEngine to the best match
*/
/**
* Retrieves the current Stage from the current Protocol and stage index
*
* @returns {*} The Stage model for the currently displayed Stage
*/
// getCurrentStageModel() {
// return this.protocol.stages[this.stage];
// }
// /**
// * Get the number of prior studies supplied in the priorStudies map property.
// *
// * @param {String} studyObjectID The study object ID of the study whose priors are needed
// * @returns {number} The number of available prior studies with the same PatientID
// */
// getNumberOfAvailablePriors(studyObjectID) {
// return this.getAvailableStudyPriors(studyObjectID).length;
// }
// /**
// * Get the array of prior studies from a specific study.
// *
// * @param {String} studyObjectID The study object ID of the study whose priors are needed
// * @returns {Array} The array of available priors or an empty array
// */
// getAvailableStudyPriors(studyObjectID) {
// const priors = this.priorStudies.get(studyObjectID);
// return priors instanceof Array ? priors : [];
// }
// // Match images given a list of Studies and a Viewport's image matching reqs
// matchImages(viewport, viewportIndex) {
// log.trace('ProtocolEngine::matchImages');
// const {
// studyMatchingRules,
// seriesMatchingRules,
// imageMatchingRules: instanceMatchingRules,
// } = viewport;
// const matchingScores = [];
// const currentStudy = this.studies[0]; // @TODO: Should this be: this.studies[this.currentStudy] ???
// const firstInstance = currentStudy.getFirstInstance();
// let highestStudyMatchingScore = 0;
// let highestSeriesMatchingScore = 0;
// // Set custom attribute for study metadata and it's first instance
// currentStudy.setCustomAttribute(ABSTRACT_PRIOR_VALUE, 0);
// if (firstInstance instanceof InstanceMetadata) {
// firstInstance.setCustomAttribute(ABSTRACT_PRIOR_VALUE, 0);
// }
// // Only used if study matching rules has abstract prior values defined...
// let priorStudies;
// studyMatchingRules.forEach(rule => {
// if (rule.attribute === ABSTRACT_PRIOR_VALUE) {
// const validatorType = Object.keys(rule.constraint)[0];
// const validator = Object.keys(rule.constraint[validatorType])[0];
// let abstractPriorValue = rule.constraint[validatorType][validator];
// abstractPriorValue = parseInt(abstractPriorValue, 10);
// // TODO: Restrict or clarify validators for abstractPriorValue?
// // No need to call it more than once...
// if (!priorStudies) {
// priorStudies = this.getAvailableStudyPriors(
// currentStudy.getObjectID()
// );
// }
// // TODO: Revisit this later: What about two studies with the same
// // study date?
// let priorStudy;
// if (abstractPriorValue === -1) {
// priorStudy = priorStudies[priorStudies.length - 1];
// } else {
// const studyIndex = Math.max(abstractPriorValue - 1, 0);
// priorStudy = priorStudies[studyIndex];
// }
// // Invalid data
// if (!priorStudy instanceof StudyMetadata) {
// return;
// }
// const priorStudyObjectID = priorStudy.getObjectID();
// // Check if study metadata is already in studies list
// if (
// this.studies.find(study => study.getObjectID() === priorStudyObjectID)
// ) {
// return;
// }
// // Get study metadata if necessary and load study in the viewer (each viewer should provide it's own load study method)
// this.studyMetadataSource.loadStudy(priorStudy).then(
// studyMetadata => {
// // Set the custom attribute abstractPriorValue for the study metadata
// studyMetadata.setCustomAttribute(
// ABSTRACT_PRIOR_VALUE,
// abstractPriorValue
// );
// // Also add custom attribute
// const firstInstance = studyMetadata.getFirstInstance();
// if (firstInstance instanceof InstanceMetadata) {
// firstInstance.setCustomAttribute(
// ABSTRACT_PRIOR_VALUE,
// abstractPriorValue
// );
// }
// // Insert the new study metadata
// this.studies.push(studyMetadata);
// // Update the viewport to refresh layout manager with new study
// this.updateViewports(viewportIndex);
// },
// error => {
// log.warn(error);
// throw new OHIFError(
// `ProtocolEngine::matchImages could not get study metadata for the Study with the following ObjectID: ${priorStudyObjectID}`
// );
// }
// );
// }
// // TODO: Add relative Date / time
// });
// this.studies.forEach(study => {
// const studyMatchDetails = HPMatcher.match(
// study.getFirstInstance(),
// studyMatchingRules
// );
// // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed
// if (
// studyMatchDetails.requiredFailed === true ||
// studyMatchDetails.score < highestStudyMatchingScore
// ) {
// return;
// }
// highestStudyMatchingScore = studyMatchDetails.score;
// study.forEachSeries(series => {
// const seriesMatchDetails = HPMatcher.match(
// series.getFirstInstance(),
// seriesMatchingRules
// );
// // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed
// if (
// seriesMatchDetails.requiredFailed === true ||
// seriesMatchDetails.score < highestSeriesMatchingScore
// ) {
// return;
// }
// highestSeriesMatchingScore = seriesMatchDetails.score;
// series.forEachInstance((instance, index) => {
// // This tests to make sure there is actually image data in this instance
// // TODO: Change this when we add PDF and MPEG support
// // See https://ohiforg.atlassian.net/browse/LT-227
// if (
// !isImage(instance.getTagValue('SOPClassUID')) &&
// !instance.getTagValue('Rows')
// ) {
// return;
// }
// const instanceMatchDetails = HPMatcher.match(
// instance,
// instanceMatchingRules
// );
// // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed
// if (instanceMatchDetails.requiredFailed === true) {
// return;
// }
// const matchDetails = {
// passed: [],
// failed: [],
// };
// matchDetails.passed = matchDetails.passed.concat(
// instanceMatchDetails.details.passed
// );
// matchDetails.passed = matchDetails.passed.concat(
// seriesMatchDetails.details.passed
// );
// matchDetails.passed = matchDetails.passed.concat(
// studyMatchDetails.details.passed
// );
// matchDetails.failed = matchDetails.failed.concat(
// instanceMatchDetails.details.failed
// );
// matchDetails.failed = matchDetails.failed.concat(
// seriesMatchDetails.details.failed
// );
// matchDetails.failed = matchDetails.failed.concat(
// studyMatchDetails.details.failed
// );
// const totalMatchScore =
// instanceMatchDetails.score +
// seriesMatchDetails.score +
// studyMatchDetails.score;
// const currentSOPInstanceUID = instance.getSOPInstanceUID();
// const imageDetails = {
// StudyInstanceUID: study.getStudyInstanceUID(),
// SeriesInstanceUID: series.getSeriesInstanceUID(),
// SOPInstanceUID: currentSOPInstanceUID,
// currentImageIdIndex: index,
// matchingScore: totalMatchScore,
// matchDetails: matchDetails,
// sortingInfo: {
// score: totalMatchScore,
// study:
// instance.getTagValue('StudyDate') +
// instance.getTagValue('StudyTime'),
// series: parseInt(instance.getTagValue('SeriesNumber')), // TODO: change for seriesDateTime
// instance: parseInt(instance.getTagValue('InstanceNumber')), // TODO: change for acquisitionTime
// },
// };
// // Find the displaySet
// const displaySet = study.findDisplaySet(displaySet =>
// displaySet.images.find(
// image => image.getSOPInstanceUID() === currentSOPInstanceUID
// )
// );
// // If the instance was found, set the displaySet ID
// if (displaySet) {
// imageDetails.displaySetInstanceUID = displaySet.getUID();
// imageDetails.imageId = instance.getImageId();
// }
// matchingScores.push(imageDetails);
// });
// });
// });
// // Sort the matchingScores
// const sortingFunction = sortBy(
// {
// name: 'score',
// reverse: true,
// },
// {
// name: 'study',
// reverse: true,
// },
// {
// name: 'instance',
// },
// {
// name: 'series',
// }
// );
// matchingScores.sort((a, b) =>
// sortingFunction(a.sortingInfo, b.sortingInfo)
// );
// const bestMatch = matchingScores[0];
// log.trace('ProtocolEngine::matchImages bestMatch', bestMatch);
// return {
// bestMatch,
// matchingScores,
// };
// }
/**
* Sets the current layout
*
* @param {number} numRows
* @param {number} numColumns
*/
// setLayout(numRows, numColumns) {
// if (numRows < 1 && numColumns < 1) {
// log.error(`Invalid layout ${numRows} x ${numColumns}`);
// return;
// }
// if (typeof this.options.setLayout !== 'function') {
// log.error('Hanging Protocol Engine setLayout callback is not defined');
// return;
// }
// let viewports = [];
// const numViewports = numRows * numColumns;
// for (let i = 0; i < numViewports; i++) {
// viewports.push({});
// }
// this.options.setLayout({ numRows, numColumns, viewports });
// }
// /**
// * Rerenders viewports that are part of the current layout manager
// * using the matching rules internal to each viewport.
// *
// * If this function is provided the index of a viewport, only the specified viewport
// * is rerendered.
// *
// * @param viewportIndex
// */
// updateViewports(viewportIndex) {
// log.trace(
// `ProtocolEngine::updateViewports viewportIndex: ${viewportIndex}`
// );
// // Make sure we have an active protocol with a non-empty array of display sets
// if (!this.getNumProtocolStages()) {
// return;
// }
// // Retrieve the current stage
// const stageModel = this.getCurrentStageModel();
// // If the current stage does not fulfill the requirements to be displayed,
// // stop here.
// if (
// !stageModel ||
// !stageModel.viewportStructure ||
// !stageModel.viewports ||
// !stageModel.viewports.length
// ) {
// return;
// }
// // Retrieve the layoutTemplate associated with the current display set's viewport structure
// // If no such template name exists, stop here.
// const layoutTemplateName = stageModel.viewportStructure.getLayoutTemplateName();
// if (!layoutTemplateName) {
// return;
// }
// // Retrieve the properties associated with the current display set's viewport structure template
// // If no such layout properties exist, stop here.
// const layoutProps = stageModel.viewportStructure.properties;
// if (!layoutProps) {
// return;
// }
// // Create an empty array to store the output viewportData
// const viewportData = [];
// // Empty the matchDetails associated with the ProtocolEngine.
// // This will be used to store the pass/fail details and score
// // for each of the viewport matching procedures
// this.matchDetails = [];
// // Loop through each viewport
// stageModel.viewports.forEach((viewport, viewportIndex) => {
// const details = this.matchImages(viewport, viewportIndex);
// this.matchDetails[viewportIndex] = details;
// // Convert any YES/NO values into true/false for Cornerstone
// const cornerstoneViewportParams = {};
// // Cache viewportSettings keys
// const viewportSettingsKeys = Object.keys(viewport.viewportSettings);
// viewportSettingsKeys.forEach(key => {
// let value = viewport.viewportSettings[key];
// if (value === 'YES') {
// value = true;
// } else if (value === 'NO') {
// value = false;
// }
// cornerstoneViewportParams[key] = value;
// });
// // imageViewerViewports occasionally needs relevant layout data in order to set
// // the element style of the viewport in question
// const currentViewportData = {
// viewportIndex,
// viewport: cornerstoneViewportParams,
// ...layoutProps,
// };
// const customSettings = [];
// viewportSettingsKeys.forEach(id => {
// const setting = CustomViewportSettings[id];
// if (!setting) {
// return;
// }
// customSettings.push({
// id: id,
// value: viewport.viewportSettings[id],
// });
// });
// currentViewportData.renderedCallback = element => {
// //console.log('renderedCallback for ' + element.id);
// customSettings.forEach(customSetting => {
// log.trace(
// `ProtocolEngine::currentViewportData.renderedCallback Applying custom setting: ${customSetting.id}`
// );
// log.trace(
// `ProtocolEngine::currentViewportData.renderedCallback with value: ${customSetting.value}`
// );
// const setting = CustomViewportSettings[customSetting.id];
// setting.callback(element, customSetting.value);
// });
// };
// let currentMatch = details.bestMatch;
// let currentPosition = 1;
// const scoresLength = details.matchingScores.length;
// while (
// currentPosition < scoresLength &&
// viewportData.find(a => a.imageId === currentMatch.imageId)
// ) {
// currentMatch = details.matchingScores[currentPosition];
// currentPosition++;
// }
// if (currentMatch && currentMatch.imageId) {
// currentViewportData.StudyInstanceUID = currentMatch.StudyInstanceUID;
// currentViewportData.SeriesInstanceUID = currentMatch.SeriesInstanceUID;
// currentViewportData.SOPInstanceUID = currentMatch.SOPInstanceUID;
// currentViewportData.currentImageIdIndex =
// currentMatch.currentImageIdIndex;
// currentViewportData.displaySetInstanceUID =
// currentMatch.displaySetInstanceUID;
// currentViewportData.imageId = currentMatch.imageId;
// }
// // @TODO Why should we throw an exception when a best match is not found? This was aborting the whole process.
// // if (!currentViewportData.displaySetInstanceUID) {
// // throw new OHIFError('ProtocolEngine::updateViewports No matching display set found?');
// // }
// viewportData.push(currentViewportData);
// });
// this.setLayout(layoutProps.Rows, layoutProps.Columns);
// if (typeof this.options.setViewportSpecificData !== 'function') {
// log.error(
// 'Hanging Protocol Engine setViewportSpecificData callback is not defined'
// );
// return;
// }
// // If viewportIndex is defined, then update only that viewport
// if (viewportIndex !== undefined && viewportData[viewportIndex]) {
// this.options.setViewportSpecificData(
// viewportIndex,
// viewportData[viewportIndex]
// );
// return;
// }
// // Update all viewports
// viewportData.forEach(viewportSpecificData => {
// this.options.setViewportSpecificData(
// viewportSpecificData.viewportIndex,
// viewportSpecificData
// );
// });
// }
// /**
// * Sets the current Hanging Protocol to the specified Protocol
// * An optional argument can also be used to prevent the updating of the Viewports
// *
// * @param newProtocol
// * @param updateViewports
// */
// setHangingProtocol(newProtocol, updateViewports = true) {
// log.trace('ProtocolEngine::setHangingProtocol newProtocol', newProtocol);
// log.trace(
// `ProtocolEngine::setHangingProtocol updateViewports = ${updateViewports}`
// );
// // Reset the array of newStageIds
// this.newStageIds = [];
// if (Protocol.prototype.isPrototypeOf(newProtocol)) {
// this.protocol = newProtocol;
// } else {
// this.protocol = new Protocol();
// this.protocol.fromObject(newProtocol);
// }
// this.stage = 0;
// // Update viewports by default
// if (updateViewports) {
// this.updateViewports();
// }
// }
// /**
// * Check if the next stage is available
// * @return {Boolean} True if next stage is available or false otherwise
// */
// isNextStageAvailable() {
// const numberOfStages = this.getNumProtocolStages();
// return this.stage + 1 < numberOfStages;
// }
// /**
// * Check if the previous stage is available
// * @return {Boolean} True if previous stage is available or false otherwise
// */
// isPreviousStageAvailable() {
// return this.stage - 1 >= 0;
// }
// /**
// * Changes the current stage to a new stage index in the display set sequence.
// * It checks if the next stage exists.
// *
// * @param {Integer} stageAction An integer value specifying wheater next (1) or previous (-1) stage
// * @return {Boolean} True if new stage has set or false, otherwise
// */
// setCurrentProtocolStage(stageAction) {
// // Check if previous or next stage is available
// if (stageAction === -1 && !this.isPreviousStageAvailable()) {
// return false;
// } else if (stageAction === 1 && !this.isNextStageAvailable()) {
// return false;
// }
// // Sets the new stage
// this.stage += stageAction;
// // Log the new stage
// log.trace(`ProtocolEngine::setCurrentProtocolStage stage = ${this.stage}`);
// // Since stage has changed, we need to update the viewports
// // and redo matchings
// this.updateViewports();
// // Everything went well
// return true;
// }
// /**
// * Retrieves the number of Stages in the current Protocol or
// * undefined if no protocol or stages are set
// */
// getNumProtocolStages() {
// if (
// !this.protocol ||
// !this.protocol.stages ||
// !this.protocol.stages.length
// ) {
// return;
// }
// return this.protocol.stages.length;
// }
// /**
// * Switches to the next protocol stage in the display set sequence
// */
// nextProtocolStage() {
// log.trace('ProtocolEngine::nextProtocolStage');
// if (!this.setCurrentProtocolStage(1)) {
// log.trace('ProtocolEngine::nextProtocolStage failed');
// }
// }
// /**
// * Switches to the previous protocol stage in the display set sequence
// */
// previousProtocolStage() {
// log.trace('ProtocolEngine::previousProtocolStage');
// if (!this.setCurrentProtocolStage(-1)) {
// log.trace('ProtocolEngine::previousProtocolStage failed');
// }
// }
// }
@@ -1,39 +1,88 @@
import validate from 'validate.js';
validate.validators.equals = function (value, options, key, attributes) {
if (options && value !== options.value) {
return key + 'must equal ' + options.value;
validate.validators.equals = function(value, options, key, attributes) {
const testValue = options?.value ?? options;
if (value !== testValue) {
return key + 'must equal ' + testValue;
}
};
validate.validators.doesNotEqual = function (value, options, key) {
if (options && value === options.value) {
return key + 'cannot equal ' + options.value;
validate.validators.doesNotEqual = function(value, options, key) {
const testValue = options?.value ?? options;
if (value === testValue) {
return key + 'cannot equal ' + testValue;
}
};
validate.validators.contains = function (value, options, key) {
if (options && value.indexOf && value.indexOf(options.value) === -1) {
return key + 'must contain ' + options.value;
validate.validators.contains = function(value, options, key) {
const testValue = options?.value ?? options;
if (Array.isArray(value)) {
if (value.some(item => !validate.validators.contains(item, options, key))) {
return undefined;
}
return `No item of ${value.join(',')} contains ${JSON.stringify(
testValue
)}`;
}
if (Array.isArray(testValue)) {
if (
testValue.some(
subTest => !validate.validators.contains(value, subTest, key)
)
) {
return;
}
return `${key} must contain at least one of ${testValue.join(',')}`;
}
if (testValue && value.indexOf && value.indexOf(testValue) === -1) {
return key + 'must contain ' + testValue;
}
};
validate.validators.doesNotContain = function (value, options, key) {
validate.validators.doesNotContain = function(value, options, key) {
if (options && value.indexOf && value.indexOf(options.value) !== -1) {
return key + 'cannot contain ' + options.value;
}
};
validate.validators.startsWith = function (value, options, key) {
validate.validators.startsWith = function(value, options, key) {
if (options && value.startsWith && !value.startsWith(options.value)) {
return key + 'must start with ' + options.value;
}
};
validate.validators.endsWith = function (value, options, key) {
validate.validators.endsWith = function(value, options, key) {
if (options && value.endsWith && !value.endsWith(options.value)) {
return key + 'must end with ' + options.value;
}
};
validate.validators.greaterThan = function(value, options, key) {
const testValue = options?.value ?? options;
if (testValue !== undefined && value <= testValue) {
return key + 'with value ' + value + ' must be greater than ' + testValue;
}
};
validate.validators.range = function(value, options, key) {
const testValue = options?.value ?? options;
if (
(testValue !== undefined && value < testValue[0]) ||
value > testValue[1]
) {
return (
key +
'with value ' +
value +
' must be between ' +
testValue[0] +
' and ' +
testValue[1]
);
}
};
validate.validators.notNull = value =>
value === null || value === undefined ? 'Value is null' : undefined;
export default validate;
@@ -0,0 +1,49 @@
import validate from "./validator.js";
describe("validator", () => {
const attributeMap = {
str: "string",
num: 3,
nullValue: null,
list: ["abc", "def"],
}
const options = {
format: 'grouped',
};
describe("contains", () => {
it("returns match any list contains", () => {
expect(validate(attributeMap, { list: { contains: 'a' } }, [options])).toBeUndefined();
expect(validate(attributeMap, { str: { contains: 'i' } }, [options])).toBeUndefined();
expect(validate(attributeMap, { str: { contains: ['i'] } }, [options])).toBeUndefined();
expect(validate(attributeMap, { list: { contains: ['a'] } }, [options])).toBeUndefined();
expect(validate(attributeMap, { list: { contains: ['z', 'd'] } }, [options])).toBeUndefined();
expect(validate(attributeMap, { list: { contains: ['z'] } }, [options])).not.toBeUndefined();
})
})
describe("equals", () => {
it("returned undefined on equals", () => {
expect(validate(attributeMap, { str: { equals: attributeMap.str } }, [options])).toBeUndefined();
expect(validate(attributeMap, { num: { equals: { value: attributeMap.num } } }, [options])).toBeUndefined();
})
it("returns error on not equals", () => {
expect(validate(attributeMap, { str: { equals: "abc" } }, [options])).not.toBeUndefined();
expect(validate(attributeMap, { num: { equals: { value: 1 + attributeMap.num } } }, [options])).not.toBeUndefined();
})
})
describe("greaterThan", () => {
it("returns undefined on greaterThan", () => {
expect(validate(attributeMap, { num: { greaterThan: { value: attributeMap.num - 1 } } }, [options])).toBeUndefined();
expect(validate(attributeMap, { num: { greaterThan: attributeMap.num - 1 } }, [options])).toBeUndefined();
})
it("returns error on not greater than", () => {
expect(validate(attributeMap, { num: { greaterThan: { value: attributeMap.num } } }, [options])).not.toBeUndefined();
expect(validate(attributeMap, { num: { greaterThan: attributeMap.num } }, [options])).not.toBeUndefined();
})
})
});