From faa0f86e674798fecfa6ef10ef42570adb5816aa Mon Sep 17 00:00:00 2001 From: Craig Berry <42152902+craigberry1@users.noreply.github.com> Date: Mon, 19 Aug 2024 11:06:06 -0400 Subject: [PATCH] feat(lossless-round-trip): Implement lossless read writes (#400) * Extract formatting logic from initial value read from data view * Leave empty string in base numeric string read, update tests with new function name. * Save original rawValue of data element as private property returned from readTag, manually apply formatting on returned Value property * Refactor to calculate raw and value inside value representation * Implement equality between original and formatted value on write, add deep equals implementation and tests * Add POC lossless-round-trip test with sample file from data repo * Add specific DS tests and first round of general VR tests * Cover all VRs with retain test * Fix exponential notation unit test expect * Update ParsedUnknownValue read logic and add different VR test coverage * Add remaining VRs for UN parsing * Formatting and cleanup * Verify changed value is respected on write * Add flag opt in/out of raw storage for things like pixel data or sequences * Add sequence tests * Fix comments and formatting before review --------- Co-authored-by: Craig Berry --- src/DicomMessage.js | 52 ++- src/ValueRepresentation.js | 224 ++++++++--- src/utilities/deepEqual.js | 35 ++ test/data.test.js | 230 +++++++++++- test/lossless-read-write.test.js | 614 +++++++++++++++++++++++++++++++ test/utilities/deepEqual.test.js | 78 ++++ 6 files changed, 1160 insertions(+), 73 deletions(-) create mode 100644 src/utilities/deepEqual.js create mode 100644 test/lossless-read-write.test.js create mode 100644 test/utilities/deepEqual.test.js diff --git a/src/DicomMessage.js b/src/DicomMessage.js index 6d31d77a..ebde2329 100644 --- a/src/DicomMessage.js +++ b/src/DicomMessage.js @@ -10,6 +10,7 @@ import { DicomDict } from "./DicomDict.js"; import { DicomMetaDictionary } from "./DicomMetaDictionary.js"; import { Tag } from "./Tag.js"; import { log } from "./log.js"; +import { deepEqual } from "./utilities/deepEqual"; import { ValueRepresentation } from "./ValueRepresentation.js"; const singleVRs = ["SQ", "OF", "OW", "OB", "UN", "LT"]; @@ -156,6 +157,7 @@ class DicomMessage { vr: readInfo.vr.type }); dict[cleanTagString].Value = readInfo.values; + dict[cleanTagString]._rawValue = readInfo.rawValues; if (untilTag && untilTag === cleanTagString) { break; @@ -193,7 +195,8 @@ class DicomMessage { ignoreErrors: false, untilTag: null, includeUntilTagValue: false, - noCopy: false + noCopy: false, + forceStoreRaw: false } ) { var stream = new ReadBufferStream(buffer, null, { @@ -251,8 +254,9 @@ class DicomMessage { sortedTags.forEach(function (tagString) { var tag = Tag.fromString(tagString), tagObject = jsonObjects[tagString], - vrType = tagObject.vr, - values = tagObject.Value; + vrType = tagObject.vr; + + var values = DicomMessage._getTagWriteValues(vrType, tagObject); written += tag.write( useStream, @@ -266,6 +270,23 @@ class DicomMessage { return written; } + static _getTagWriteValues(vrType, tagObject) { + if (!tagObject._rawValue) { + return tagObject.Value; + } + + // apply VR specific formatting to the original _rawValue and compare to the Value + const vr = ValueRepresentation.createByTypeString(vrType); + const originalValue = tagObject._rawValue.map((val) => vr.applyFormatting(val)) + + // if Value has not changed, write _rawValue unformatted back into the file + if (deepEqual(tagObject.Value, originalValue)) { + return tagObject._rawValue; + } else { + return tagObject.Value; + } + } + static _readTag( stream, syntax, @@ -340,25 +361,33 @@ class DicomMessage { } var values = []; + var rawValues = []; if (vr.isBinary() && length > vr.maxLength && !vr.noMultiple) { var times = length / vr.maxLength, i = 0; while (i++ < times) { - values.push(vr.read(stream, vr.maxLength, syntax)); + const { rawValue, value } = vr.read(stream, vr.maxLength, syntax, options); + rawValues.push(rawValue); + values.push(value); } } else { - var val = vr.read(stream, length, syntax); + const { rawValue, value } = vr.read(stream, length, syntax, options); if (!vr.isBinary() && singleVRs.indexOf(vr.type) == -1) { - values = val; - if (typeof val === "string") { - values = val.split(String.fromCharCode(VM_DELIMITER)); + rawValues = rawValue; + values = value + if (typeof value === "string") { + rawValues = rawValue.split(String.fromCharCode(VM_DELIMITER)); + values = value.split(String.fromCharCode(VM_DELIMITER)); } } else if (vr.type == "SQ") { - values = val; + rawValues = rawValue; + values = value; } else if (vr.type == "OW" || vr.type == "OB") { - values = val; + rawValues = rawValue; + values = value; } else { - Array.isArray(val) ? (values = val) : values.push(val); + Array.isArray(value) ? (values = value) : values.push(value); + Array.isArray(rawValue) ? (rawValues = rawValue) : rawValues.push(rawValue); } } stream.setEndian(oldEndian); @@ -368,6 +397,7 @@ class DicomMessage { vr: vr }); retObj.values = values; + retObj.rawValues = rawValues; return retObj; } diff --git a/src/ValueRepresentation.js b/src/ValueRepresentation.js index 7115aa85..59f6e9ea 100644 --- a/src/ValueRepresentation.js +++ b/src/ValueRepresentation.js @@ -79,6 +79,7 @@ class ValueRepresentation { this._allowMultiple = !this._isBinary && singleVRs.indexOf(this.type) == -1; this._isExplicit = explicitVRs.indexOf(this.type) != -1; + this._storeRaw = true; } static setDicomMessageClass(dicomMessageClass) { @@ -101,6 +102,17 @@ class ValueRepresentation { return this._isExplicit; } + /** + * Flag that specifies whether to store the original unformatted value that is read from the dicom input buffer. + * The `_rawValue` is used for lossless round trip processing, which preserves data (whitespace, special chars) on write + * that may be lost after casting to other data structures like Number, or applying formatting for readability. + * + * Example DecimalString: _rawValue: ["-0.000"], Value: [0] + */ + storeRaw() { + return this._storeRaw; + } + addValueAccessors(value) { return value; } @@ -124,7 +136,7 @@ class ValueRepresentation { return tag; } - read(stream, length, syntax) { + read(stream, length, syntax, readOptions = { forceStoreRaw: false }) { if (this.fixed && this.maxLength) { if (!length) return this.defaultValue; if (this.maxLength != length) @@ -137,7 +149,19 @@ class ValueRepresentation { length ); } - return this.readBytes(stream, length, syntax); + let rawValue = this.readBytes(stream, length, syntax); + const value = this.applyFormatting(rawValue); + + // avoid duplicating large binary data structures like pixel data which are unlikely to be formatted or directly manipulated + if (!this.storeRaw() && !readOptions.forceStoreRaw) { + rawValue = undefined; + } + + return { rawValue, value }; + } + + applyFormatting(value) { + return value; } readBytes(stream, length) { @@ -322,6 +346,7 @@ class EncodedStringRepresentation extends ValueRepresentation { class BinaryRepresentation extends ValueRepresentation { constructor(type) { super(type); + this._storeRaw = false; } writeBytes(stream, value, syntax, isEncapsulated, writeOptions = {}) { @@ -562,7 +587,11 @@ class ApplicationEntity extends AsciiStringRepresentation { } readBytes(stream, length) { - return stream.readAsciiString(length).trim(); + return stream.readAsciiString(length); + } + + applyFormatting(value) { + return value.trim(); } } @@ -574,7 +603,18 @@ class CodeString extends AsciiStringRepresentation { } readBytes(stream, length) { - return stream.readAsciiString(length).trim(); + const BACKSLASH = String.fromCharCode(VM_DELIMITER); + return stream.readAsciiString(length).split(BACKSLASH); + } + + applyFormatting(value) { + const trim = (str) => str.trim(); + + if (Array.isArray(value)) { + return value.map((str) => trim(str)); + } + + return trim(value); } } @@ -630,20 +670,29 @@ class DecimalString extends AsciiStringRepresentation { readBytes(stream, length) { const BACKSLASH = String.fromCharCode(VM_DELIMITER); - let ds = stream.readAsciiString(length); - ds = ds.replace(/[^0-9.\\\-+e]/gi, ""); + const ds = stream.readAsciiString(length); if (ds.indexOf(BACKSLASH) !== -1) { // handle decimal string with multiplicity - const dsArray = ds.split(BACKSLASH); - ds = dsArray.map(ds => (ds === "" ? null : Number(ds))); - } else { - ds = [ds === "" ? null : Number(ds)]; + return ds.split(BACKSLASH); } - return ds; + return [ds]; } - formatValue(value) { + applyFormatting(value) { + const formatNumber = (numberStr) => { + let returnVal = numberStr.trim().replace(/[^0-9.\\\-+e]/gi, ""); + return returnVal === "" ? null : Number(returnVal); + } + + if (Array.isArray(value)) { + return value.map(formatNumber); + } + + return formatNumber(value); + } + + convertToString(value) { if (value === null) return ""; let str = String(value); @@ -681,8 +730,8 @@ class DecimalString extends AsciiStringRepresentation { writeBytes(stream, value, writeOptions) { const val = Array.isArray(value) - ? value.map(ds => this.formatValue(ds)) - : [this.formatValue(value)]; + ? value.map(ds => this.convertToString(ds)) + : [this.convertToString(value)]; return super.writeBytes(stream, val, writeOptions); } } @@ -705,7 +754,11 @@ class FloatingPointSingle extends ValueRepresentation { } readBytes(stream) { - return Number(stream.readFloat()); + return stream.readFloat(); + } + + applyFormatting(value) { + return Number(value); } writeBytes(stream, value, writeOptions) { @@ -728,7 +781,11 @@ class FloatingPointDouble extends ValueRepresentation { } readBytes(stream) { - return Number(stream.readDouble()); + return stream.readDouble(); + } + + applyFormatting(value) { + return Number(value); } writeBytes(stream, value, writeOptions) { @@ -750,29 +807,37 @@ class IntegerString extends AsciiStringRepresentation { readBytes(stream, length) { const BACKSLASH = String.fromCharCode(VM_DELIMITER); - let is = stream.readAsciiString(length).trim(); - - is = is.replace(/[^0-9.\\\-+e]/gi, ""); + const is = stream.readAsciiString(length); if (is.indexOf(BACKSLASH) !== -1) { // handle integer string with multiplicity - const integerStringArray = is.split(BACKSLASH); - is = integerStringArray.map(is => (is === "" ? null : Number(is))); - } else { - is = [is === "" ? null : Number(is)]; + return is.split(BACKSLASH); + } + + return [is]; + } + + applyFormatting(value) { + const formatNumber = (numberStr) => { + let returnVal = numberStr.trim().replace(/[^0-9.\\\-+e]/gi, ""); + return returnVal === "" ? null : Number(returnVal); + } + + if (Array.isArray(value)) { + return value.map(formatNumber); } - return is; + return formatNumber(value); } - formatValue(value) { + convertToString(value) { return value === null ? "" : String(value); } writeBytes(stream, value, writeOptions) { const val = Array.isArray(value) - ? value.map(is => this.formatValue(is)) - : [this.formatValue(value)]; + ? value.map(is => this.convertToString(is)) + : [this.convertToString(value)]; return super.writeBytes(stream, val, writeOptions); } } @@ -785,7 +850,11 @@ class LongString extends EncodedStringRepresentation { } readBytes(stream, length) { - return stream.readEncodedString(length).trim(); + return stream.readEncodedString(length); + } + + applyFormatting(value) { + return value.trim(); } } @@ -797,7 +866,11 @@ class LongText extends EncodedStringRepresentation { } readBytes(stream, length) { - return rtrim(stream.readEncodedString(length)); + return stream.readEncodedString(length); + } + + applyFormatting(value) { + return rtrim(value); } } @@ -870,8 +943,17 @@ class PersonName extends EncodedStringRepresentation { } readBytes(stream, length) { - const result = this.readPaddedEncodedString(stream, length); - return dicomJson.pnConvertToJsonObject(result); + return this.readPaddedEncodedString(stream, length).split(String.fromCharCode(VM_DELIMITER)); + } + + applyFormatting(value) { + const parsePersonName = (valueStr) => dicomJson.pnConvertToJsonObject(valueStr, false); + + if (Array.isArray(value)) { + return value.map((valueStr) => parsePersonName(valueStr)); + } + + return parsePersonName(value); } writeBytes(stream, value, writeOptions) { @@ -891,7 +973,11 @@ class ShortString extends EncodedStringRepresentation { } readBytes(stream, length) { - return stream.readEncodedString(length).trim(); + return stream.readEncodedString(length); + } + + applyFormatting(value) { + return value.trim(); } } @@ -924,6 +1010,7 @@ class SequenceOfItems extends ValueRepresentation { this.maxLength = null; this.padByte = PADDING_NULL; this.noMultiple = true; + this._storeRaw = false; } readBytes(stream, sqlength, syntax) { @@ -1086,7 +1173,11 @@ class ShortText extends EncodedStringRepresentation { } readBytes(stream, length) { - return rtrim(stream.readEncodedString(length)); + return stream.readEncodedString(length); + } + + applyFormatting(value) { + return rtrim(value); } } @@ -1098,7 +1189,11 @@ class TimeValue extends AsciiStringRepresentation { } readBytes(stream, length) { - return rtrim(stream.readAsciiString(length)); + return stream.readAsciiString(length); + } + + applyFormatting(value) { + return rtrim(value); } } @@ -1111,7 +1206,11 @@ class UnlimitedCharacters extends EncodedStringRepresentation { } readBytes(stream, length) { - return rtrim(stream.readEncodedString(length)); + return stream.readEncodedString(length); + } + + applyFormatting(value) { + return rtrim(value); } } @@ -1123,7 +1222,11 @@ class UnlimitedText extends EncodedStringRepresentation { } readBytes(stream, length) { - return rtrim(stream.readEncodedString(length)); + return stream.readEncodedString(length); + } + + applyFormatting(value) { + return rtrim(value); } } @@ -1184,7 +1287,6 @@ class UniqueIdentifier extends AsciiStringRepresentation { const result = this.readPaddedAsciiString(stream, length); const BACKSLASH = String.fromCharCode(VM_DELIMITER); - const uidRegExp = /[^0-9.]/g; // Treat backslashes as a delimiter for multiple UIDs, in which case an // array of UIDs is returned. This is used by DICOM Q&R to support @@ -1195,12 +1297,22 @@ class UniqueIdentifier extends AsciiStringRepresentation { // https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.4.html if (result.indexOf(BACKSLASH) === -1) { - return result.replace(uidRegExp, ""); - } else { return result - .split(BACKSLASH) - .map(uid => uid.replace(uidRegExp, "")); + } else { + return result.split(BACKSLASH) + } + } + + applyFormatting(value) { + const removeInvalidUidChars = (uidStr) => { + return uidStr.replace(/[^0-9.]/g, ""); } + + if (Array.isArray(value)) { + return value.map(removeInvalidUidChars); + } + + return removeInvalidUidChars(value); } } @@ -1234,37 +1346,29 @@ class ParsedUnknownValue extends BinaryRepresentation { this._isBinary = true; this._allowMultiple = false; this._isExplicit = true; + this._storeRaw = true; } - read(stream, length, syntax) { + read(stream, length, syntax, readOptions) { const arrayBuffer = this.readBytes(stream, length, syntax)[0]; const streamFromBuffer = new ReadBufferStream(arrayBuffer, true); const vr = ValueRepresentation.createByTypeString(this.type); - var values = []; if (vr.isBinary() && length > vr.maxLength && !vr.noMultiple) { + var values = []; + var rawValues = []; var times = length / vr.maxLength, i = 0; + while (i++ < times) { - values.push(vr.read(streamFromBuffer, vr.maxLength, syntax)); + const { rawValue, value } = vr.read(streamFromBuffer, vr.maxLength, syntax, readOptions); + rawValues.push(rawValue); + values.push(value); } + return { rawValue: rawValues, value: values }; } else { - var val = vr.read(streamFromBuffer, length, syntax); - if (!vr.isBinary() && singleVRs.indexOf(vr.type) == -1) { - values = val; - if (typeof val === "string") { - values = val.split(String.fromCharCode(VM_DELIMITER)); - } - } else if (vr.type == "SQ") { - values = val; - } else if (vr.type == "OW" || vr.type == "OB") { - values = val; - } else { - Array.isArray(val) ? (values = val) : values.push(val); - } + return vr.read(streamFromBuffer, length, syntax, readOptions); } - - return values; } } @@ -1338,4 +1442,4 @@ let VRinstances = { UT: new UnlimitedText() }; -export { ValueRepresentation }; +export {ValueRepresentation}; diff --git a/src/utilities/deepEqual.js b/src/utilities/deepEqual.js new file mode 100644 index 00000000..3537f70b --- /dev/null +++ b/src/utilities/deepEqual.js @@ -0,0 +1,35 @@ +/** + * Performs a deep equality check between two objects. Used primarily during DICOM write operations + * to determine whether a data element underlying value has changed since it was initially read. + * + * @param {Object} obj1 - The first object to compare. + * @param {Object} obj2 - The second object to compare. + * @returns {boolean} - Returns `true` if the structures and values of the objects are deeply equal, `false` otherwise. + */ +export function deepEqual(obj1, obj2) { + // Use Object.is to consider for treatment of `NaN` and signed 0's i.e. `+0` or `-0` in IS/DS + if (Object.is(obj1, obj2)) { + return true; + } + + // expect objects or a null instance if initial check failed + if (typeof obj1 !== 'object' || typeof obj2 !== 'object' || obj1 === null || obj2 === null) { + return false; + } + + // all keys should match a deep equality check + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + + if (keys1.length !== keys2.length) { + return false; + } + + for (const key of keys1) { + if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) { + return false; + } + } + + return true; +} \ No newline at end of file diff --git a/test/data.test.js b/test/data.test.js index d1e62aad..8dca1803 100644 --- a/test/data.test.js +++ b/test/data.test.js @@ -357,7 +357,9 @@ it("test_exponential_notation", () => { dataset.ImagePositionPatient[2] = 7.1945578383e-5; const buffer = data.write(); const copy = dcmjs.data.DicomMessage.readFile(buffer); - expect(JSON.stringify(data)).toEqual(JSON.stringify(copy)); + const datasetCopy = dcmjs.data.DicomMetaDictionary.naturalizeDataset(copy.dict); + + expect(dataset.ImagePositionPatient).toEqual(datasetCopy.ImagePositionPatient); }); it("test_output_equality", () => { @@ -1128,6 +1130,230 @@ describe("test_un_vr", () => { expect(dataset.ExposureIndex).toEqual(expectedExposureIndex); expect(dataset.DeviationIndex).toEqual(expectedDeviationIndex); }); + + describe("Test other VRs encoded as UN", () => { + test.each([ + [ + '00000600', + 'AE', + new Uint8Array([0x20, 0x20, 0x54, 0x45, 0x53, 0x54, 0x5F, 0x41, 0x45, 0x20]).buffer, + [" TEST_AE "], + ["TEST_AE"] + ], + [ + '00101010', + 'AS', + new Uint8Array([0x30, 0x34, 0x35, 0x59]).buffer, + ["045Y"], + ["045Y"] + ], + [ + '00280009', + 'AT', + new Uint8Array([0x63, 0x10, 0x18, 0x00]).buffer, + [0x10630018], + [0x10630018] + ], + [ + '00041130', + 'CS', + new Uint8Array([0x4F, 0x52, 0x49, 0x47, 0x49, 0x4E, 0x41, 0x4C, 0x20, 0x20, 0x5C, 0x20, 0x50, 0x52, 0x49, 0x4D, 0x41, 0x52, 0x59, 0x20]).buffer, + ["ORIGINAL ", " PRIMARY "], + ["ORIGINAL", "PRIMARY"] + ], + [ + '00181012', + 'DA', + new Uint8Array([0x32, 0x30, 0x32, 0x34, 0x30, 0x31, 0x30, 0x31]).buffer, + ["20240101"], + ["20240101"] + ], + [ + '00181041', + 'DS', + new Uint8Array([0x30, 0x30, 0x30, 0x30, 0x31, 0x32, 0x33, 0x2E, 0x34, 0x35]).buffer, + ["0000123.45"], + [123.45] + ], + [ + '00181078', + 'DT', + new Uint8Array([0x32, 0x30, 0x32, 0x34, 0x30, 0x31, 0x30, 0x31, 0x31, 0x32, 0x33, 0x30, 0x34, 0x35, 0x2E, 0x31, 0x20, 0x20]).buffer, + ["20240101123045.1 "], + ["20240101123045.1 "] + ], + [ + '00182043', + 'FL', + new Uint8Array([0x66, 0x66, 0xA6, 0x3F, 0x66, 0x66, 0xA6, 0x3F]).buffer, + [1.2999999523162842, 1.2999999523162842], + [1.2999999523162842, 1.2999999523162842] + ], + [ + '00186028', + 'FD', + new Uint8Array([0x11, 0x2D, 0x44, 0x54, 0xFB, 0x21, 0x09, 0x40]).buffer, + [3.14159265358979], + [3.14159265358979] + ], + [ + '00200012', + 'IS', + new Uint8Array([0x20,0x2B,0x32,0x37,0x38,0x39,0x33,0x20]).buffer, + [" +27893 "], + [27893] + ], + [ + '0018702A', + 'LO', + new Uint8Array([0x20,0x20,0x46,0x65,0x65,0x6C,0x69,0x6E,0x67,0x20,0x6E,0x61,0x75,0x73,0x65,0x6F,0x75,0x73,0x20,0x20]).buffer, + [" Feeling nauseous "], + ["Feeling nauseous"] + ], + [ + '00187040', + 'LT', + new Uint8Array([0x20,0x20,0x46,0x65,0x65,0x6C,0x69,0x6E,0x67,0x20,0x6E,0x61,0x75,0x73,0x65,0x6F,0x75,0x73,0x20,0x20]).buffer, + [" Feeling nauseous "], + [" Feeling nauseous"] + ], + [ + '00282000', + 'OB', + new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer, + [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer] + ], + [ + '00701A07', + 'OD', + new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer, + [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer], + [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer] + ], + [ + '00720067', + 'OF', + new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer, + [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer], + [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer] + ], + [ + '00281224', + 'OW', + new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer, + [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer] + ], + [ + '00080090', + 'PN', + new Uint8Array([0x44, 0x6F, 0x65, 0x5E, 0x4A, 0x6F, 0x68, 0x6E, 0x5E, 0x41, 0x5E, 0x4A, 0x72, 0x2E, 0x5E, 0x4D, 0x44, 0x3D, 0x44, 0x6F, 0x65, 0x5E, 0x4A, 0x61, 0x79, 0x5E, 0x41, 0x5E, 0x4A, 0x72, 0x2E, 0x20]).buffer, + ["Doe^John^A^Jr.^MD=Doe^Jay^A^Jr."], + [{"Alphabetic": "Doe^John^A^Jr.^MD", "Ideographic": "Doe^Jay^A^Jr."}] + ], + [ + '00080094', + 'SH', + new Uint8Array([0x43,0x54,0x5F,0x53,0x43,0x41,0x4E,0x5F,0x30,0x31]).buffer, + ["CT_SCAN_01"], + ["CT_SCAN_01"] + ], + [ + '00186020', + 'SL', + new Uint8Array([0x40, 0xE2, 0x01, 0x00, 0x40, 0xE2, 0x01, 0x00]).buffer, + [123456, 123456], + [123456, 123456] + ], + [ + '00189219', + 'SS', + new Uint8Array([0xD2, 0x04, 0xD2, 0x04, 0xD2, 0x04]).buffer, + [1234, 1234, 1234], + [1234, 1234, 1234] + ], + [ + '00189373', + 'ST', + new Uint8Array([0x20,0x20,0x46,0x65,0x65,0x6C,0x69,0x6E,0x67,0x20,0x6E,0x61,0x75,0x73,0x65,0x6F,0x75,0x73,0x20,0x20]).buffer, + [" Feeling nauseous "], + [" Feeling nauseous"] + ], + [ + '21000050', + 'TM', + new Uint8Array([0x34,0x32,0x35,0x33,0x30,0x2E,0x31,0x32,0x33,0x34,0x35,0x36]).buffer, + ["42530.123456"], + ["42530.123456"] + ], + [ + '3010001B', + 'UC', + new Uint8Array([0x54, 0x72, 0x61, 0x69, 0x6C, 0x69, 0x6E, 0x67, 0x20, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x20, 0x61, 0x6C, 0x6C, 0x6F, 0x77, 0x65, 0x64, 0x20, 0x20, 0x20]).buffer, + ["Trailing spaces allowed "], + ["Trailing spaces allowed"] + ], + [ + '00041510', + 'UI', + new Uint8Array([0x31,0x2E,0x32,0x2E,0x38,0x34,0x30,0x2E,0x31,0x30,0x30,0x30,0x38,0x2E,0x31,0x2E,0x32,0x2E,0x31]).buffer, + ["1.2.840.10008.1.2.1"], + ["1.2.840.10008.1.2.1"] + ], + [ + '30100092', + 'UL', + new Uint8Array([0x40, 0xE2, 0x01, 0x00]).buffer, + [123456], + [123456] + ], + [ + '0008010E', + 'UR', + new Uint8Array([0x68,0x74,0x74,0x70,0x3A,0x2F,0x2F,0x64,0x69,0x63,0x6F,0x6D,0x2E,0x6E,0x65,0x6D,0x61,0x2E,0x6F,0x72,0x67, 0x20]).buffer, + ["http://dicom.nema.org "], + ["http://dicom.nema.org "], + ], + [ + '00080301', + 'US', + new Uint8Array([0xD2, 0x04]).buffer, + [1234], + [1234], + ], + [ + '0008030E', + 'UT', + new Uint8Array([0x20,0x20,0x46,0x65,0x65,0x6C,0x69,0x6E,0x67,0x20,0x6E,0x61,0x75,0x73,0x65,0x6F,0x75,0x73,0x20,0x20]).buffer, + [" Feeling nauseous "], + [" Feeling nauseous"] + ], + ])( + "for tag %s with expected VR %p", + (tag, vr, byteArray, expectedRawValue, expectedValue) => { + // setup input tag as UN + const dataset = { + [tag]: { + vr: "UN", + _rawValue: [byteArray], + Value: [byteArray], + }, + }; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // Write and re-read + const outputDicomDict = DicomMessage.readFile(dicomDict.write(), { forceStoreRaw: true }); + + // Expect tag to be parsed correctly based on meta dictionary vr lookup + expect(outputDicomDict.dict[tag].vr).toEqual(vr); + expect(outputDicomDict.dict[tag]._rawValue).toEqual(expectedRawValue); + expect(outputDicomDict.dict[tag].Value).toEqual(expectedValue); + } + ); + }); }); it.each([ @@ -1149,7 +1375,7 @@ it.each([ "A converted decimal string should not exceed 16 bytes in length", (a, expected) => { const decimalString = ValueRepresentation.createByTypeString("DS"); - let value = decimalString.formatValue(a); + let value = decimalString.convertToString(a); expect(value.length).toBeLessThanOrEqual(16); expect(value).toBe(expected); } diff --git a/test/lossless-read-write.test.js b/test/lossless-read-write.test.js new file mode 100644 index 00000000..ad1c43c0 --- /dev/null +++ b/test/lossless-read-write.test.js @@ -0,0 +1,614 @@ +import "regenerator-runtime/runtime.js"; + +import fs from "fs"; +import dcmjs from "../src/index.js"; +import {deepEqual} from "../src/utilities/deepEqual"; + +import {getTestDataset} from "./testUtils"; + +const {DicomDict, DicomMessage} = dcmjs.data; + + +describe('lossless-read-write', () => { + + describe('storeRaw option', () => { + const dataset = { + '00080008': { + vr: "CS", + Value: ["DERIVED"], + }, + "00082112": { + vr: "SQ", + Value: [ + { + "00081150": { + vr: "UI", + Value: [ + "1.2.840.10008.5.1.4.1.1.7" + ], + }, + } + ] + }, + "00180050": { + vr: "DS", + Value: [1], + }, + "00181708": { + vr: "IS", + Value: [426], + }, + "00189328": { + vr: "FD", + Value: [30.98], + }, + "0020000D": { + vr: "UI", + Value: ["1.3.6.1.4.1.5962.99.1.2280943358.716200484.1363785608958.3.0"], + }, + "00400254": { + vr: "LO", + Value: ["DUCTO/GALACTOGRAM 1 DUCT LT"], + }, + "7FE00010": { + vr: "OW", + Value: [new Uint8Array([0x00, 0x00]).buffer] + } + }; + + test('storeRaw flag on VR should be respected by read', () => { + const tagsWithoutRaw = ['00082112', '7FE00010']; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // write and re-read + const outputDicomDict = DicomMessage.readFile(dicomDict.write()); + for (const tag in outputDicomDict.dict) { + if (tagsWithoutRaw.includes(tag)) { + expect(outputDicomDict.dict[tag]._rawValue).toBeFalsy(); + } else { + expect(outputDicomDict.dict[tag]._rawValue).toBeTruthy(); + } + } + }); + + test('forceStoreRaw read option should override VR setting', () => { + const tagsWithoutRaw = ['00082112', '7FE00010']; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // write and re-read + const outputDicomDict = DicomMessage.readFile(dicomDict.write(), {forceStoreRaw: true}); + + for (const tag in outputDicomDict.dict) { + expect(outputDicomDict.dict[tag]._rawValue).toBeTruthy(); + } + }); + }); + + test('test DS value with additional allowed characters is written to file', () => { + const dataset = { + '00181041': { + _rawValue: [" +1.4000 ", "-0.00", "1.2345e2", "1E34"], + Value: [1.4, -0, 123.45, 1e+34], + vr: 'DS' + }, + }; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // write and re-read + const outputDicomDict = DicomMessage.readFile(dicomDict.write()); + + // expect raw value to be unchanged, and Value parsed as Number to lose precision + expect(outputDicomDict.dict['00181041']._rawValue).toEqual([" +1.4000 ", "-0.00", "1.2345e2", "1E34"]) + expect(outputDicomDict.dict['00181041'].Value).toEqual([1.4, -0, 123.45, 1e+34]) + }); + + test('test DS value that exceeds Number.MAX_SAFE_INTEGER is written to file', () => { + const dataset = { + '00181041': { + _rawValue: ["9007199254740993"], + Value: [9007199254740993], + vr: 'DS' + }, + }; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // write and re-read + const outputDicomDict = DicomMessage.readFile(dicomDict.write()); + + // expect raw value to be unchanged, and Value parsed as Number to lose precision + expect(outputDicomDict.dict['00181041']._rawValue).toEqual(["9007199254740993"]) + expect(outputDicomDict.dict['00181041'].Value).toEqual([9007199254740992]) + }); + + describe('Individual VR comparisons', () => { + + const unchangedTestCases = [ + { + vr: "AE", + _rawValue: [" TEST_AE "], // spaces non-significant for interpretation but allowed + Value: ["TEST_AE"], + }, + { + vr: "AS", + _rawValue: ["045Y"], + Value: ["045Y"], + }, + { + vr: "AT", + _rawValue: [0x00207E14, 0x0012839A], + Value: [0x00207E14, 0x0012839A], + }, + { + vr: "CS", + _rawValue: ["ORIGINAL ", " PRIMARY "], // spaces non-significant for interpretation but allowed + Value: ["ORIGINAL", "PRIMARY"], + }, + { + vr: "DA", + _rawValue: ["20240101"], + Value: ["20240101"], + }, + { + vr: "DS", + _rawValue: ["0000123.45"], // leading zeros allowed + Value: [123.45], + }, + { + vr: 'DT', + _rawValue: ["20240101123045.1 "], // trailing spaces allowed + Value: ["20240101123045.1 "], + }, + { + vr: 'FL', + _rawValue: [3.125], + Value: [3.125], + }, + { + vr: 'FD', + _rawValue: [3.14159265358979], // trailing spaces allowed + Value: [3.14159265358979], + }, + { + vr: 'IS', + _rawValue: [" -123 "], // leading/trailing spaces & sign allowed + Value: [-123], + }, + { + vr: 'LO', + _rawValue: [" A long string with spaces "], // leading/trailing spaces allowed + Value: ["A long string with spaces"], + }, + { + vr: 'LT', + _rawValue: [" It may contain the Graphic Character set and the Control Characters, CR\r, LF\n, FF\f, and ESC\x1b. "], // leading spaces significant, trailing spaces allowed + Value: [" It may contain the Graphic Character set and the Control Characters, CR\r, LF\n, FF\f, and ESC\x1b."], + }, + { + vr: 'OB', + _rawValue: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + Value: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + }, + { + vr: 'OD', + _rawValue: [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer], + Value: [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer], + }, + { + vr: 'OF', + _rawValue: [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer], + Value: [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer], + }, + // TODO: VRs currently unimplemented + // { + // vr: 'OL', + // _rawValue: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0xF6, 0x42, 0x00, 0x00, 0x28, 0x41]).buffer], + // Value: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0xF6, 0x42, 0x00, 0x00, 0x28, 0x41]).buffer], + // }, + // { + // vr: 'OV', + // _rawValue: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41]).buffer], + // Value: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41]).buffer], + // }, + { + vr: 'OW', + _rawValue: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + Value: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + }, + { + vr: 'PN', + _rawValue: ["Doe^John^A^Jr.^MD "], // trailing spaces allowed + Value: [{"Alphabetic": "Doe^John^A^Jr.^MD "}], + }, + { + vr: 'SH', + _rawValue: [" CT_SCAN_01 "], // leading/trailing spaces allowed + Value: ["CT_SCAN_01"], + }, + { + vr: 'SL', + _rawValue: [-2147483648], + Value: [-2147483648], + }, + { + vr: 'SS', + _rawValue: [-32768, 1234, 832], + Value: [-32768, 1234, 832], + }, + { + vr: 'ST', + _rawValue: ["Patient complains of headaches over the last week. "], // trailing spaces allowed + Value: ["Patient complains of headaches over the last week."], + }, + // TODO: VR currently unimplemented + // { + // vr: 'SV', + // _rawValue: [9007199254740993], // trailing spaces allowed + // Value: [9007199254740993], + // }, + { + vr: 'TM', + _rawValue: ["42530.123456 "], // trailing spaces allowed + Value: ["42530.123456"], + }, + { + vr: 'UC', + _rawValue: ["Detailed description of procedure or clinical notes that could be very long. "], // trailing spaces allowed + Value: ["Detailed description of procedure or clinical notes that could be very long."], + }, + { + vr: 'UI', + _rawValue: ["1.2.840.10008.1.2.1"], + Value: ["1.2.840.10008.1.2.1"], + }, + { + vr: 'UL', + _rawValue: [4294967295], + Value: [4294967295], + }, + { + vr: 'UR', + _rawValue: ["http://dicom.nema.org "], // trailing spaces ignored but allowed + Value: ["http://dicom.nema.org "], + }, + { + vr: 'US', + _rawValue: [65535], + Value: [65535], + }, + { + vr: 'UT', + _rawValue: [" This is a detailed explanation that can span multiple lines and paragraphs in the DICOM dataset. "], // leading spaces significant, trailing spaces allowed + Value: [" This is a detailed explanation that can span multiple lines and paragraphs in the DICOM dataset."], + }, + // TODO: VR currently unimplemented + // { + // vr: 'UV', + // _rawValue: [18446744073709551616], // 2^64 + // Value: [18446744073709551616], + // }, + ]; + test.each(unchangedTestCases)( + `Test unchanged value is retained following read and write - $vr`, + (dataElement) => { + const dataset = { + '00181041': { + ...dataElement + }, + }; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // write and re-read + const outputDicomDict = DicomMessage.readFile(dicomDict.write(), {forceStoreRaw: true}); + + // expect raw value to be unchanged, and Value parsed as Number to lose precision + expect(outputDicomDict.dict['00181041']._rawValue).toEqual(dataElement._rawValue) + expect(outputDicomDict.dict['00181041'].Value).toEqual(dataElement.Value) + } + ) + + const changedTestCases = [ + { + vr: "AE", + _rawValue: [" TEST_AE "], // spaces non-significant for interpretation but allowed + Value: ["NEW_AE"], + }, + { + vr: "AS", + _rawValue: ["045Y"], + Value: ["999Y"], + }, + { + vr: "AT", + _rawValue: [0x00207E14, 0x0012839A], + Value: [0x00200010], + }, + { + vr: "CS", + _rawValue: ["ORIGINAL ", " PRIMARY "], // spaces non-significant for interpretation but allowed + Value: ["ORIGINAL", "PRIMARY", "SECONDARY"], + }, + { + vr: "DA", + _rawValue: ["20240101"], + Value: ["20231225"], + }, + { + vr: "DS", + _rawValue: ["0000123.45"], // leading zeros allowed + Value: [123.456], + newRawValue: ["123.456 "] + }, + { + vr: 'DT', + _rawValue: ["20240101123045.1 "], // trailing spaces allowed + Value: ["20240101123045.3"], + }, + { + vr: 'FL', + _rawValue: [3.125], + Value: [22], + }, + { + vr: 'FD', + _rawValue: [3.14159265358979], // trailing spaces allowed + Value: [50.1242], + }, + { + vr: 'IS', + _rawValue: [" -123 "], // leading/trailing spaces & sign allowed + Value: [0], + newRawValue: ["0 "] + }, + { + vr: 'LO', + _rawValue: [" A long string with spaces "], // leading/trailing spaces allowed + Value: ["A changed string that is still long."], + }, + { + vr: 'LT', + _rawValue: [" It may contain the Graphic Character set and the Control Characters, CR\r, LF\n, FF\f, and ESC\x1b. "], // leading spaces significant, trailing spaces allowed + Value: [" A modified string of text"], + }, + { + vr: 'OB', + _rawValue: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + Value: [new Uint8Array([0x01, 0x02]).buffer], + }, + { + vr: 'OD', + _rawValue: [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x34, 0x6F, 0x9D, 0x41]).buffer], + Value: [new Uint8Array([0x00, 0x00, 0x00, 0x54, 0x35, 0x6E, 0x9E, 0x42]).buffer], + }, + { + vr: 'OF', + _rawValue: [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x42]).buffer], + Value: [new Uint8Array([0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0xF6, 0x43]).buffer], + }, + // TODO: VRs currently unimplemented + // { + // vr: 'OL', + // _rawValue: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41, 0x00, 0x00, 0xF6, 0x42, 0x00, 0x00, 0x28, 0x41]).buffer], + // }, + // { + // vr: 'OV', + // _rawValue: [new Uint8Array([0x00, 0x00, 0x30, 0xC0, 0x00, 0x00, 0x28, 0x41]).buffer], + // }, + { + vr: 'OW', + _rawValue: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x89, 0x91, 0x89, 0x89]).buffer], + Value: [new Uint8Array([0x13, 0x40, 0x80, 0x88, 0x88, 0x90, 0x88, 0x88]).buffer], + }, + { + vr: 'PN', + _rawValue: ["Doe^John^A^Jr.^MD "], // trailing spaces allowed + Value: [{"Alphabetic": "Doe^Jane^A^Jr.^MD"}], + newRawValue: ["Doe^Jane^A^Jr.^MD"] + }, + { + vr: 'SH', + _rawValue: [" CT_SCAN_01 "], // leading/trailing spaces allowed + Value: ["MR_SCAN_91"], + }, + { + vr: 'SL', + _rawValue: [-2147483648], + Value: [-2147481234], + }, + { + vr: 'SS', + _rawValue: [-32768, 1234, 832], + Value: [1234], + }, + { + vr: 'ST', + _rawValue: ["Patient complains of headaches over the last week. "], // trailing spaces allowed + Value: ["Patient complains of headaches"], + }, + // TODO: VR currently unimplemented + // { + // vr: 'SV', + // _rawValue: [9007199254740993], // trailing spaces allowed + // }, + { + vr: 'TM', + _rawValue: ["42530.123456 "], // trailing spaces allowed + Value: ["42530"], + newRawValue: ["42530 "] + }, + { + vr: 'UC', + _rawValue: ["Detailed description of procedure or clinical notes that could be very long. "], // trailing spaces allowed + Value: ["Detailed description of procedure and other things"], + }, + { + vr: 'UI', + _rawValue: ["1.2.840.10008.1.2.1"], + Value: ["1.2.840.10008.1.2.2"], + }, + { + vr: 'UL', + _rawValue: [4294967295], + Value: [1], + }, + { + vr: 'UR', + _rawValue: ["http://dicom.nema.org "], // trailing spaces ignored but allowed + Value: ["https://github.com/dcmjs-org"], + }, + { + vr: 'US', + _rawValue: [65535], + Value: [1], + }, + { + vr: 'UT', + _rawValue: [" This is a detailed explanation that can span multiple lines and paragraphs in the DICOM dataset. "], // leading spaces significant, trailing spaces allowed + Value: [""], + }, + // TODO: VR currently unimplemented + // { + // vr: 'UV', + // _rawValue: [18446744073709551616], // 2^64 + // }, + ]; + + test.each(changedTestCases)( + `Test changed value overwrites original value following read and write - $vr`, + (dataElement) => { + const dataset = { + '00181041': { + ...dataElement + }, + }; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // write and re-read + const outputDicomDict = DicomMessage.readFile(dicomDict.write(), {forceStoreRaw: true}); + + // expect raw value to be updated to match new Value parsed as Number to lose precision + expect(outputDicomDict.dict['00181041']._rawValue).toEqual(dataElement.newRawValue ?? dataElement.Value) + expect(outputDicomDict.dict['00181041'].Value).toEqual(dataElement.Value) + } + ) + + }); + + describe('sequences', () => { + test('nested sequences should support lossless round trip', () => { + const dataset = { + "52009229": { + vr: "SQ", + Value: [ + { + "0020000E": { + vr: "UI", + Value: ["1.3.6.1.4.1.5962.99.1.2280943358.716200484.1363785608958.1.1"] + }, + "00089123": { + vr: "SQ", + Value: [ + { + "00181030": { + vr: "AE", + _rawValue: [" TEST_AE "], + Value: ["TEST_AE"], + }, + "00180050": { + vr: "DS", + Value: [5.0], + _rawValue: ["5.000 "] + } + }, + { + "00181030": { + vr: "AE", + _rawValue: [" TEST_AE "], + Value: ["TEST_AE"], + }, + "00180050": { + vr: "DS", + Value: [6.0], + _rawValue: ["6.000 "] + } + } + ] + } + }, + { + "0020000E": { + vr: "UI", + Value: ["1.3.6.1.4.1.5962.99.1.2280943358.716200484.1363785608958.1.2"] + }, + "00089123": { + vr: "SQ", + Value: [ + { + "00181030": { + vr: "LO", + Value: ["ABDOMEN MRI"] + }, + "00180050": { + vr: 'IS', + _rawValue: [" -123 "], // leading/trailing spaces & sign allowed + Value: [-123], + } + } + ] + } + } + ] + } + }; + + const dicomDict = new DicomDict({}); + dicomDict.dict = dataset; + + // confirm after write raw values are re-encoded + const outputBuffer = dicomDict.write(); + const outputDicomDict = DicomMessage.readFile(outputBuffer); + + // lossless read/write should match entire data set + deepEqual(dicomDict.dict, outputDicomDict.dict) + }) + }) + + test('File dataset should be equal after read and write', async () => { + const inputBuffer = await getDcmjsDataFile("unknown-VR", "sample-dicom-with-un-vr.dcm"); + const dicomDict = DicomMessage.readFile(inputBuffer); + + // confirm raw string representation of DS contains extra additional metadata + // represented by bytes [30 2E 31 34 30 5C 30 2E 31 34 30 20] + expect(dicomDict.dict['00280030']._rawValue).toEqual(["0.140", "0.140 "]) + expect(dicomDict.dict['00280030'].Value).toEqual([0.14, 0.14]) + + // confirm after write raw values are re-encoded + const outputBuffer = dicomDict.write(); + const outputDicomDict = DicomMessage.readFile(outputBuffer); + + // explicitly verify for DS for clarity + expect(outputDicomDict.dict['00280030']._rawValue).toEqual(["0.140", "0.140 "]) + expect(outputDicomDict.dict['00280030'].Value).toEqual([0.14, 0.14]) + + // lossless read/write should match entire data set + deepEqual(dicomDict.dict, outputDicomDict.dict) + }); +}); + +const getDcmjsDataFile = async (release, fileName) => { + const url = "https://github.com/dcmjs-org/data/releases/download/" + release + "/" + fileName; + const dcmPath = await getTestDataset(url, fileName); + + return fs.readFileSync(dcmPath).buffer; +} diff --git a/test/utilities/deepEqual.test.js b/test/utilities/deepEqual.test.js new file mode 100644 index 00000000..700000d0 --- /dev/null +++ b/test/utilities/deepEqual.test.js @@ -0,0 +1,78 @@ +import { deepEqual } from "../../src/utilities/deepEqual"; + +describe('deepEqual', () => { + test('returns true for identical primitives', () => { + expect(deepEqual(42, 42)).toBe(true); + expect(deepEqual('hello', 'hello')).toBe(true); + expect(deepEqual(true, true)).toBe(true); + expect(deepEqual(null, null)).toBe(true); + }); + + test('returns false for different primitives', () => { + expect(deepEqual(42, 43)).toBe(false); + expect(deepEqual('hello', 'world')).toBe(false); + expect(deepEqual(true, false)).toBe(false); + expect(deepEqual(null, undefined)).toBe(false); + }); + + test('returns same value check for signed zeros and special numbers', () => { + expect(deepEqual(Math.NaN, Math.NaN)).toBe(true); + expect(deepEqual(-0, 0)).toBe(false); + expect(deepEqual(-0, +0)).toBe(false); + }) + + test('returns true for deeply equal objects', () => { + const obj1 = { a: 1, b: { c: 2 } }; + const obj2 = { a: 1, b: { c: 2 } }; + expect(deepEqual(obj1, obj2)).toBe(true); + }); + + test('returns false for objects with different structures', () => { + const obj1 = { a: 1, b: { c: 2 } }; + const obj2 = { a: 1, b: { d: 2 } }; + expect(deepEqual(obj1, obj2)).toBe(false); + }); + + test('returns false for objects with different values', () => { + const obj1 = { a: 1, b: { c: 2 } }; + const obj2 = { a: 1, b: { c: 3 } }; + expect(deepEqual(obj1, obj2)).toBe(false); + }); + + test('returns true for deeply equal arrays', () => { + const arr1 = [1, 2, { a: 3 }]; + const arr2 = [1, 2, { a: 3 }]; + expect(deepEqual(arr1, arr2)).toBe(true); + }); + + test('returns false for arrays with different values', () => { + const arr1 = [1, 2, { a: 3 }]; + const arr2 = [1, 2, { a: 4 }]; + expect(deepEqual(arr1, arr2)).toBe(false); + }); + + test('returns false for objects compared with arrays', () => { + const obj = { a: 1, b: 2 }; + const arr = [1, 2]; + expect(deepEqual(obj, arr)).toBe(false); + }); + + test('returns false for different object types', () => { + const date1 = new Date(2024, 0, 1); + const date2 = new Date(2024, 0, 1); + const obj1 = { a: 1, b: 2 }; + expect(deepEqual(date1, obj1)).toBe(false); + }); + + test('returns true for nested objects with arrays', () => { + const obj1 = { a: 1, b: [1, 2, { c: 3 }] }; + const obj2 = { a: 1, b: [1, 2, { c: 3 }] }; + expect(deepEqual(obj1, obj2)).toBe(true); + }); + + test('returns false for functions, as they should not be equal', () => { + const obj1 = { a: 1, b: function() { return 2; } }; + const obj2 = { a: 1, b: function() { return 2; } }; + expect(deepEqual(obj1, obj2)).toBe(false); + }); +}); \ No newline at end of file