@cowwoc/requirements
Version:
A fluent API for enforcing design contracts with automatic message generation.
361 lines • 15.8 kB
JavaScript
import { Configuration, DiffGenerator, DiffResult, Type, EOL_PATTERN, assertThatStringIsNotEmpty, assert, ContextSection, StringSection, assertThatType, isApplicationScope, assertThatInstanceOf, ValidationTarget, AssertionError, MessageBuilder } from "../../internal.mjs";
import isEqual from "lodash.isequal";
/**
* Returns the difference between two values as an error context.
*/
class ContextGenerator {
scope;
configuration;
diffGenerator;
/**
* The name of the actual value.
*/
_actualName;
/**
* The actual value.
*/
_actualValue = ValidationTarget.invalid();
/**
* The name of the expected value.
*/
_expectedName;
/**
* The expected value.
*/
_expectedValue = ValidationTarget.invalid();
/**
* `true` if error messages may include a diff that compares actual and expected values.
*/
_allowDiff;
/**
* `true` if the output may include an explanation of the diff format.
*/
_allowLegend = false;
/**
* Creates a ContextGenerator.
*
* @param scope - the application configuration
* @param configuration - the validator configuration
* @param actualName - the name of the actual value
* @param expectedName - the name of the expected value
* @throws AssertionError if:
* <ul>
* <li>any of the arguments is null</li>
* <li>`actualName` or `expectedName` are blank</li>
* <li>`actualName` or `expectedName` contains a colon</li>
* </ul>
*/
constructor(scope, configuration, actualName, expectedName) {
assertThatInstanceOf(configuration, "configuration", Configuration);
assertThatType(scope, "scope", Type.namedClass("ApplicationScope", () => isApplicationScope(scope)));
assertThatStringIsNotEmpty(actualName, "actualName");
assert(!actualName.includes(":"), undefined, `actualName may not contain a colon.
actualName: ${actualName}`);
assertThatStringIsNotEmpty(expectedName, "expectedName");
assert(!expectedName.includes(":"), undefined, `expectedName may not contain a colon.
expectedName: ${expectedName}`);
this.scope = scope;
this.configuration = configuration;
this.diffGenerator = new DiffGenerator(scope.getGlobalConfiguration().terminalEncoding());
this._allowDiff = configuration.allowDiff();
this._actualName = actualName;
this._expectedName = expectedName;
}
/**
* Sets the actual value.
*
* @param value - the object representation of the actual value
* @returns this
*/
actualValue(value) {
this._actualValue = ValidationTarget.valid(value);
return this;
}
/**
* Sets the expected value.
*
* @param value - the object representation of the expected value
* @returns this
*/
expectedValue(value) {
this._expectedValue = ValidationTarget.valid(value);
return this;
}
/**
* Overrides the value of {@link Configuration.allowDiff}.
*
* @param allowDiff - `true` if error messages may include a diff that compares actual and expected
* values
* @returns this
*/
allowDiff(allowDiff) {
this._allowDiff = allowDiff;
return this;
}
/**
* Determines if the output may include a legend of the diff format.
*
* @param allowLegend - `true` if the output may include an explanation of the diff format
* return this
*/
allowLegend(allowLegend) {
this._allowLegend = allowLegend;
return this;
}
/**
* @returns the diff to append to the error message
*/
build() {
assert(this._actualValue.isValid() || this._expectedValue.isValid(), undefined, "actualValue and expectedValue were both invalid");
if (this._actualValue.map(v => Array.isArray(v)).or(false) &&
this._expectedValue.map(v => Array.isArray(v)).or(false)) {
return this.getContextOfList();
}
return this.getContextOfObjects();
}
/**
* @param actualName - the name of the actual value
* @param actualValue - the value of the actual value
* @param diff - the difference between the two values (empty if absent)
* @param expectedName - the name of the expected value
* @param expectedValue - the value of the expected value
* @returns the difference between the expected and actual values
*/
getDiffSection(actualName, actualValue, diff, expectedName, expectedValue) {
const value = new Map();
value.set(actualName, actualValue);
if (diff.length !== 0)
value.set("diff", diff);
value.set(expectedName, expectedValue);
return new ContextSection(value);
}
/**
* Generates a List-specific error context from the actual and expected values.
*
* @returns the difference between the expected and actual values
* @throws AssertionError if the actual or expected values do not exist
*/
getContextOfList() {
const actualAsArray = this._actualValue.orThrow(() => new AssertionError("actualValue was invalid"));
const expectedAsArray = this._expectedValue.orThrow(() => new AssertionError("actualValue was invalid"));
const actualSize = actualAsArray.length;
const expectedSize = expectedAsArray.length;
const maxSize = Math.max(actualSize, expectedSize);
const components = [];
// Indicates if the previous index was equal
let skippedEqualElements = false;
let actualIndex = 0;
let expectedIndex = 0;
for (let i = 0; i < maxSize; ++i) {
let elementsAreEqual = true;
const actualLineExists = i < actualSize;
let actualNameLine;
let actualValueLine;
if (actualLineExists) {
actualNameLine = `${this._actualName}[${actualIndex}]`;
actualValueLine = ValidationTarget.valid(actualAsArray[i]);
++actualIndex;
}
else {
actualNameLine = this._actualName;
actualValueLine = ValidationTarget.invalid();
elementsAreEqual = false;
}
const expectedLineExists = i < expectedSize;
let expectedNameLine;
let expectedValueLine;
if (expectedLineExists) {
expectedNameLine = `${this._expectedName}[${expectedIndex}]`;
expectedValueLine = ValidationTarget.valid(expectedAsArray[i]);
++expectedIndex;
}
else {
expectedNameLine = this._expectedName;
expectedValueLine = ValidationTarget.invalid();
elementsAreEqual = false;
}
const elementGenerator = new ContextGenerator(this.scope, this.configuration, actualNameLine, expectedNameLine).
allowLegend(false);
actualValueLine.ifValid(value => elementGenerator.actualValue(value));
expectedValueLine.ifValid(value => elementGenerator.expectedValue(value));
elementsAreEqual &&= isEqual(actualValueLine, expectedValueLine);
if (i !== 0 && i !== maxSize - 1 && elementsAreEqual) {
// Skip identical elements, unless they are the first or last element.
skippedEqualElements = true;
continue;
}
if (skippedEqualElements) {
skippedEqualElements = false;
components.push(ContextGenerator.skipEqualLines());
}
if (components.length !== 0) {
// Insert an empty line between each diff section
components.push(new StringSection(""));
}
components.push(...elementGenerator.build());
}
return components;
}
/**
* Returns context entries to indicate that duplicate lines were skipped.
*
* @returns the context entries to append
*/
static skipEqualLines() {
return new StringSection(`
[...]`);
}
/**
* Generates an error context from the actual and expected values.
*
* @returns the difference between the expected and actual values
*/
getContextOfObjects() {
assert(this._actualValue.isValid() || this._expectedValue.isValid(), undefined, "actualValue and expectedValue were both invalid");
const stringMappers = this.configuration.stringMappers();
const actualAsString = this._actualValue.map(v => stringMappers.toString(v)).or("");
const expectedAsString = this._expectedValue.map(v => stringMappers.toString(v)).or("");
const lines = this.diffGenerator.diff(actualAsString, expectedAsString);
const diffLinesExist = lines.getDiffLines().length !== 0;
// When comparing multiline strings, this method is invoked one line at a time. If the actual or expected
// value is invalid, it indicates that one of the values contains more lines than the other. The value
// with fewer lines will be considered invalid on a per-line basis.
const numberOfLines = lines.getActualLines().length;
// Don't diff boolean values
if (!this._allowDiff || numberOfLines == 1 ||
this._actualValue.map(v => v instanceof Boolean).or(false) ||
this._expectedValue.map(v => v instanceof Boolean).or(false)) {
return this.getContextForSingleLine(lines);
}
let actualLineNumber = 0;
let expectedLineNumber = 0;
const actualLines = lines.getActualLines();
const expectedLines = lines.getExpectedLines();
const equalLines = lines.getEqualLines();
// Indicates if the previous line was equal
let skippedEqualLines = false;
const context = [];
for (let i = 0; i < numberOfLines; ++i) {
const valuesAreEqual = equalLines[i];
if (i !== 0 && i !== numberOfLines - 1 && valuesAreEqual) {
// Skip equal lines, unless they are the first or last line.
skippedEqualLines = true;
++actualLineNumber;
++expectedLineNumber;
continue;
}
const actualValueLine = ContextGenerator.getElementOrEmptyString(actualLines, i);
let actualNameLine;
if (this.diffGenerator.isEmpty(actualValueLine))
actualNameLine = this._actualName;
else {
actualNameLine = `${this._actualName}@${actualLineNumber}`;
if (EOL_PATTERN.test(actualValueLine))
++actualLineNumber;
}
let diffLine;
if (diffLinesExist && !valuesAreEqual)
diffLine = lines.getDiffLines()[i];
else
diffLine = "";
const expectedValueLine = ContextGenerator.getElementOrEmptyString(expectedLines, i);
let expectedNameLine;
if (this.diffGenerator.isEmpty(expectedValueLine))
expectedNameLine = this._expectedName;
else {
expectedNameLine = `${this._expectedName}@${expectedLineNumber}`;
if (EOL_PATTERN.test(expectedValueLine))
++expectedLineNumber;
}
if (skippedEqualLines) {
skippedEqualLines = false;
context.push(ContextGenerator.skipEqualLines());
}
if (context.length !== 0)
context.push(new StringSection(""));
const elementGenerator = new ContextGenerator(this.scope, this.configuration, actualNameLine, expectedNameLine).
actualValue(actualValueLine).
expectedValue(expectedValueLine);
context.push(elementGenerator.getDiffSection(actualNameLine, actualValueLine, diffLine, expectedNameLine, expectedValueLine));
}
if (diffLinesExist && this._allowLegend)
context.push(new StringSection(MessageBuilder.DIFF_LEGEND));
return context;
}
/**
* @param list - a list
* @param i - an index
* @returns the element at the specified index, or `""` if the index is out of bounds
*/
static getElementOrEmptyString(list, i) {
if (list.length > i)
return list[i];
return "";
}
getContextForSingleLine(lines) {
let actualAsString;
let expectedAsString;
if (lines.getActualLines().length > 1 || lines.getExpectedLines().length > 1) {
const stringMappers = this.configuration.stringMappers();
actualAsString = this._actualValue.map(v => stringMappers.toString(v)).or("");
expectedAsString = this._expectedValue.map(v => stringMappers.toString(v)).or("");
}
else {
actualAsString = lines.getActualLines()[0];
expectedAsString = lines.getExpectedLines()[0];
}
const diffLinesExist = lines.getDiffLines().length !== 0;
const valuesAreEqual = lines.getEqualLines()[0];
let diffLine;
if (diffLinesExist && !valuesAreEqual)
diffLine = lines.getDiffLines()[0];
else
diffLine = "";
const context = [];
context.push(this.getDiffSection(this._actualName, actualAsString, diffLine, this._expectedName, expectedAsString));
if (this._actualValue !== this._expectedValue && this.stringRepresentationsAreEqual(lines)) {
// If the String representation of the values is equal, output getClass(), hashCode(),
// or System.identityHashCode() to figure out why they differ.
const optionalContext = this.compareTypes();
if (optionalContext.length !== 0) {
context.push(new StringSection(""));
context.push(...optionalContext);
}
}
return context;
}
/**
* @param lines - the result of comparing the actual and expected values
* @returns `true` if the string representation of the values is equal
*/
stringRepresentationsAreEqual(lines) {
return lines.getEqualLines().every((value) => value);
}
/**
* @returns the difference between the expected and actual values
* @throws TypeError if `actualName` or `expectedName` are `undefined` or `null`
*/
compareTypes() {
assert(this._actualValue.isValid() || this._expectedValue.isValid(), undefined, "actualValue and expectedValue were both invalid");
const actualTypeName = Type.of(this._actualValue);
const expectedTypeName = Type.of(this._expectedValue);
if (!isEqual(actualTypeName, expectedTypeName)) {
return new ContextGenerator(this.scope, this.configuration, `${this._actualName}.type`, `${this._expectedName}.type`).
actualValue(actualTypeName).
expectedValue(expectedTypeName).
allowDiff(false).
build();
}
return [];
}
toString() {
const stringMappers = this.configuration.stringMappers();
let result = `actualName: ${this._actualName}`;
this._actualValue.ifValid(v => result += `, actualValue: ${stringMappers.toString(v)}`);
result += `, expectedName: ${this._expectedName}`;
this._expectedValue.ifValid(v => result += `, expectedValue: ${stringMappers.toString(v)}`);
return result;
}
}
export { ContextGenerator };
//# sourceMappingURL=ContextGenerator.mjs.map