From cbda83f2324c8290cbd098656d170553131dee3b Mon Sep 17 00:00:00 2001 From: "Adrien Minne (adrm)" Date: Wed, 3 Jun 2026 11:40:02 +0200 Subject: [PATCH 1/2] [REF] tests: remove custom `toBeCloseTo` matcher We re-defined the standard `toBeCloseTo` matcher in order to use it in `expect(...).toMatchObject({ x: expect.toBeCloseTo(...)})`. But we can just use `expect.closeTo(1000)` instead. Task: 6272392 --- tests/helpers/text_helper.test.ts | 108 +++++++++++++++--------------- tests/setup/jest_extend.ts | 15 ----- 2 files changed, 54 insertions(+), 69 deletions(-) diff --git a/tests/helpers/text_helper.test.ts b/tests/helpers/text_helper.test.ts index 1ab09c1e20..5c326eb5ac 100644 --- a/tests/helpers/text_helper.test.ts +++ b/tests/helpers/text_helper.test.ts @@ -19,8 +19,8 @@ describe("computeRotationPosition", () => { test.each([0, Math.PI * 2])("No rotation", (rotation) => { expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(1000), - y: expect.toBeCloseTo(2000), + x: expect.closeTo(1000), + y: expect.closeTo(2000), }); }); @@ -30,8 +30,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); @@ -41,8 +41,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y - sin * textWidth; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); }); @@ -52,8 +52,8 @@ describe("computeRotationPosition", () => { test.each([0, Math.PI * 2])("No rotation", (rotation) => { expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(1000), - y: expect.toBeCloseTo(2000), + x: expect.closeTo(1000), + y: expect.closeTo(2000), }); }); @@ -63,8 +63,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y + (sin * textWidth) / 2; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); @@ -74,8 +74,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y - (sin * textWidth) / 2; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); }); @@ -85,8 +85,8 @@ describe("computeRotationPosition", () => { test.each([0, Math.PI * 2])("No rotation", (rotation) => { expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(1000), - y: expect.toBeCloseTo(2000), + x: expect.closeTo(1000), + y: expect.closeTo(2000), }); }); @@ -96,8 +96,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y + sin * textWidth; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); @@ -107,8 +107,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); }); @@ -118,8 +118,8 @@ describe("computeRotationPosition", () => { test.each([0, Math.PI * 2])("No rotation", (rotation) => { expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(1000), - y: expect.toBeCloseTo(2000), + x: expect.closeTo(1000), + y: expect.closeTo(2000), }); }); @@ -129,8 +129,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y - (sin * textWidth) / 2; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); @@ -141,8 +141,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y - (sin * textWidth) / 2 + (cos * textHeight) / 4; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); }); @@ -152,8 +152,8 @@ describe("computeRotationPosition", () => { test.each([0, Math.PI * 2])("No rotation", (rotation) => { expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(1000), - y: expect.toBeCloseTo(2000), + x: expect.closeTo(1000), + y: expect.closeTo(2000), }); }); @@ -163,8 +163,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y - textHeight / 2 + sin * textHeight; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); @@ -173,8 +173,8 @@ describe("computeRotationPosition", () => { const newX = textBox.x + (sin * textHeight) / 2; const newY = textBox.y - textHeight / 2 - sin * textHeight; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); }); @@ -184,8 +184,8 @@ describe("computeRotationPosition", () => { test.each([0, Math.PI * 2])("No rotation", (rotation) => { expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(1000), - y: expect.toBeCloseTo(2000), + x: expect.closeTo(1000), + y: expect.closeTo(2000), }); }); @@ -196,8 +196,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y + (sin * textWidth) / 2 + (cos * textHeight) / 4; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); @@ -207,8 +207,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y + (sin * textWidth) / 2; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); }); @@ -218,8 +218,8 @@ describe("computeRotationPosition", () => { test.each([0, Math.PI * 2])("No rotation", (rotation) => { expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(1000), - y: expect.toBeCloseTo(2000), + x: expect.closeTo(1000), + y: expect.closeTo(2000), }); }); @@ -230,8 +230,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y + (1 - cos) * textHeight - sin * textWidth; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); @@ -241,8 +241,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y + (1 - cos) * textHeight; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); }); @@ -252,8 +252,8 @@ describe("computeRotationPosition", () => { test.each([0, Math.PI * 2])("No rotation", (rotation) => { expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(1000), - y: expect.toBeCloseTo(2000), + x: expect.closeTo(1000), + y: expect.closeTo(2000), }); }); @@ -264,8 +264,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y + (1 - cos) * textHeight - (sin * textWidth) / 2; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); @@ -276,8 +276,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y + (1 - cos) * textHeight + (sin * textWidth) / 2; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); }); @@ -287,8 +287,8 @@ describe("computeRotationPosition", () => { test.each([0, Math.PI * 2])("No rotation", (rotation) => { expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(1000), - y: expect.toBeCloseTo(2000), + x: expect.closeTo(1000), + y: expect.closeTo(2000), }); }); @@ -298,8 +298,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y + (1 - cos) * textHeight; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); @@ -310,8 +310,8 @@ describe("computeRotationPosition", () => { const newY = textBox.y + (1 - cos) * textHeight + sin * textWidth; expect(computeRotationPosition(textBox, { ...style, rotation })).toMatchObject({ - x: expect.toBeCloseTo(rotate(newX, newY, rotation).x), - y: expect.toBeCloseTo(rotate(newX, newY, rotation).y), + x: expect.closeTo(rotate(newX, newY, rotation).x), + y: expect.closeTo(rotate(newX, newY, rotation).y), }); }); }); diff --git a/tests/setup/jest_extend.ts b/tests/setup/jest_extend.ts index 6d3bdb1ec8..330726ba71 100644 --- a/tests/setup/jest_extend.ts +++ b/tests/setup/jest_extend.ts @@ -46,7 +46,6 @@ declare global { interface Expect { toBeBetween(lower: number, upper: number): ExpectResult; toBeSameColorAs(expected: string, tolerance?: number): ExpectResult; - toBeCloseTo(expected: number, closeDigit?: number): ExpectResult; } } } @@ -197,20 +196,6 @@ CancelledReasons: ${this.utils.printReceived(dispatchResult.reasons)} } return { pass: true, message: () => "" }; }, - toBeCloseTo(received: number, expected: number, numDigits?: number) { - numDigits = numDigits ?? -2; - const pass = Math.abs(expected - received) < 10 ** numDigits / 2; - if (pass) { - return { pass: true, message: () => "" }; - } - return { - pass: false, - message: () => - `Expected ${received} to be close to ${expected} with a tolerance of ${ - 10 ** numDigits / 2 - }`, - }; - }, toBeSameColorAs(received: string, expected: string, tolerance: number = 0) { let pass = false; if (received.startsWith("light-dark") || expected.startsWith("light-dark")) { From 98328ee5fcf00687bcd5487cb6eafea5ccf43a29 Mon Sep 17 00:00:00 2001 From: "Adrien Minne (adrm)" Date: Wed, 3 Jun 2026 11:15:37 +0200 Subject: [PATCH 2/2] [IMP] tests: add tests for custom jest matchers We define custom jest matcher (eg. `expect(color).toBeSameColorAs(otherColor)`) but never actually test that those work correctly. They, in fact, did not. - most of the matchers had wrong error message when used with `expect().not` - `expect().not.toExport` didn't work correctly (but was never used) Most of the error message tests are written with snapshots, because we use jest helpers to color/prettify the output, and testing those is a pain. Task: 6272392 --- .../jest_custom_matchers.test.ts.snap | 219 +++++++++++++ tests/jest_custom_matchers.test.ts | 291 ++++++++++++++++++ tests/setup/jest_extend.ts | 226 +++++++------- 3 files changed, 621 insertions(+), 115 deletions(-) create mode 100644 tests/__snapshots__/jest_custom_matchers.test.ts.snap create mode 100644 tests/jest_custom_matchers.test.ts diff --git a/tests/__snapshots__/jest_custom_matchers.test.ts.snap b/tests/__snapshots__/jest_custom_matchers.test.ts.snap new file mode 100644 index 0000000000..f065762db3 --- /dev/null +++ b/tests/__snapshots__/jest_custom_matchers.test.ts.snap @@ -0,0 +1,219 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`toBeCancelledBecause matches cancelled dispatch reasons 1`] = ` +" +The command should have been cancelled: +Expected: ["WillRemoveExistingMerge"] +Received: ["CancelledForUnknownReason"] +" +`; + +exports[`toBeSuccessfullyDispatched can use not.toBeSuccessfullyDispatched 1`] = `"The command should not have been successfully dispatched"`; + +exports[`toBeSuccessfullyDispatched matches successful dispatch results 1`] = ` +" +The command should have been successfully dispatched: +CancelledReasons: ["CancelledForUnknownReason"] +" +`; + +exports[`toExport can use not.toExport 1`] = ` +"Diff: - Not expected - 1 ++ Received + 1 + +@@ -3,11 +3,11 @@ + "customTableStyles": Object {}, + "formats": Object {}, + "namedRanges": Object {}, + "pivotNextId": 1, + "pivots": Object {}, +- "revisionId": "random-id", ++ "revisionId": "mock-uuidv4-2", + "settings": Object { + "locale": Object { + "code": "en_US", + "dateFormat": "m/d/yyyy", + "decimalSeparator": "."," +`; + +exports[`toExport ignores the revision id and compares exported data 1`] = ` +"Diff: - Expected - 2 ++ Received + 4 + +@@ -3,11 +3,11 @@ + "customTableStyles": Object {}, + "formats": Object {}, + "namedRanges": Object {}, + "pivotNextId": 1, + "pivots": Object {}, +- "revisionId": "START_REVISION", ++ "revisionId": "mock-uuidv4-2", + "settings": Object { + "locale": Object { + "code": "en_US", + "dateFormat": "m/d/yyyy", + "decimalSeparator": ".", +@@ -20,11 +20,13 @@ + }, + "sheets": Array [ + Object { + "areGridLinesVisible": true, + "borders": Object {}, +- "cells": Object {}, ++ "cells": Object { ++ "A1": "42", ++ }, + "colNumber": 26, + "color": undefined, + "cols": Object {}, + "conditionalFormats": Array [], + "dataValidationRules": Array []," +`; + +exports[`toHaveAttribute can use not.toHaveAttribute 1`] = ` +"expect(target).not.toHaveAttribute(aria-label, expected); + +Unexpected attribute value: "Confirm" +Received value: serializes to the same string" +`; + +exports[`toHaveAttribute matches attribute values 1`] = ` +"expect(target).toHaveAttribute(aria-label, expected); + +Expected attribute value: "Cancel" +Received value: "Confirm"" +`; + +exports[`toHaveClass can match an element classes 1`] = ` +"expect(target).toHaveClass(expected); + +Expected class: "gamma" +Received classes: "box alpha beta"" +`; + +exports[`toHaveClass can use not.toHaveClass 1`] = ` +"expect(target).not.toHaveClass(expected); + +Unexpected class: "alpha" +Received classes: "box alpha beta"" +`; + +exports[`toHaveCount can use not.toHaveCount 1`] = ` +"expect(".item").not.toHaveCount(expected); + +Unexpected count: 2 +Received: serializes to the same string" +`; + +exports[`toHaveCount counts matching elements 1`] = ` +"expect(".item").toHaveCount(expected); + +Expected count: 1 +Received: 2" +`; + +exports[`toHaveStyle can use not.toHaveStyle 1`] = ` +"expect(target).not.toHaveStyle(expected); + +Unexpected style: {"display": "block"} +Received style: serializes to the same string" +`; + +exports[`toHaveStyle compares inline styles and normalizes rgb colors 1`] = ` +"expect(target).toHaveStyle(expected); + +- Expected style - 1 ++ Received style + 1 + + Object { +- "color": "#00FF00", ++ "color": "#FF0000", + }" +`; + +exports[`toHaveSynchronizedEvaluation compares evaluated cells across models 1`] = ` +"alice and mock-smallUuid-7 are not synchronized: +- alice - 1 ++ mock-smallUuid-7 + 1 + +@@ -1,9 +1,9 @@ + Array [ + Object { + "sheetId": "Sheet1", +- "value": 2, ++ "value": null, + "xc": "A1", + }, + Object { + "sheetId": "Sheet1", + "value": null," +`; + +exports[`toHaveSynchronizedExportedData compares exported workbook data 1`] = ` +"alice and mock-smallUuid-9 are not synchronized: +- alice - 4 ++ mock-smallUuid-9 + 2 + +@@ -3,11 +3,11 @@ + "customTableStyles": Object {}, + "formats": Object {}, + "namedRanges": Object {}, + "pivotNextId": 1, + "pivots": Object {}, +- "revisionId": "mock-uuidv4-6", ++ "revisionId": "START_REVISION", + "settings": Object { + "locale": Object { + "code": "en_US", + "dateFormat": "m/d/yyyy", + "decimalSeparator": ".", +@@ -20,13 +20,11 @@ + }, + "sheets": Array [ + Object { + "areGridLinesVisible": true, + "borders": Object {}, +- "cells": Object { +- "A1": "42", +- }, ++ "cells": Object {}, + "colNumber": 26, + "color": undefined, + "cols": Object {}, + "conditionalFormats": Array [], + "dataValidationRules": Array []," +`; + +exports[`toHaveSynchronizedValue compares the callback result across models 1`] = ` +"Alice does not have the expected value: +Received: "2" +Expected: "1"" +`; + +exports[`toHaveText Can use not.toHaveText 1`] = ` +"expect(target).not.toHaveText(expected); + +Unexpected text: "Exact text" +Received text: serializes to the same string" +`; + +exports[`toHaveText matches exact text content 1`] = ` +"expect(target).toHaveText(expected); + +Expected text: "Other text" +Received text: "Exact text"" +`; + +exports[`toHaveValue Can use not.toHaveValue 1`] = ` +"expect(target).not.toHaveValue(expected); + +Unexpected value: "hello" +Received value: serializes to the same string" +`; + +exports[`toHaveValue supports text inputs and checkbox 1`] = ` +"expect(target).toHaveValue(expected); + +Expected value: "world" +Received value: "hello"" +`; diff --git a/tests/jest_custom_matchers.test.ts b/tests/jest_custom_matchers.test.ts new file mode 100644 index 0000000000..39e1ac26fc --- /dev/null +++ b/tests/jest_custom_matchers.test.ts @@ -0,0 +1,291 @@ +import { CommandResult, DispatchResult, Model } from "../src"; +import { UuidGenerator } from "../src/helpers/uuid"; +import { setupCollaborativeEnv } from "./collaborative/collaborative_helpers"; +import { getCellContent, setCellContent } from "./test_helpers"; +import { createModelFromGrid } from "./test_helpers/helpers"; + +function setDom(html: string) { + document.body.innerHTML = html; +} + +beforeEach(() => { + let uuidCounter = 0; + jest + .spyOn(UuidGenerator, "smallUuid") + .mockImplementation(() => `mock-smallUuid-${uuidCounter++}`); + jest.spyOn(UuidGenerator, "uuidv4").mockImplementation(() => `mock-uuidv4-${uuidCounter++}`); +}); + +describe("toBeBetween", () => { + test("Bounds are inclusive", () => { + expect(3).toBeBetween(3, 5); + expect(5).toBeBetween(3, 5); + expect(() => expect(2).toBeBetween(3, 5)).toThrow("Expected 2 to be between 3 and 5"); + }); + + test("can use not.toBeBetween", () => { + expect(2).not.toBeBetween(3, 5); + expect(6).not.toBeBetween(3, 5); + expect(() => expect(4).not.toBeBetween(3, 5)).toThrow("Expected 4 not to be between 3 and 5"); + }); +}); + +describe("toBeSameColorAs", () => { + test("compares equivalent colors", () => { + expect("rgb(255, 0, 0)").toBeSameColorAs("#ff0000"); + expect("#FfFfFf").toBeSameColorAs("#ffffFF"); + expect(() => expect("#ff0000").toBeSameColorAs("#00ff00")).toThrow( + "Expected #ff0000 to be equivalent to #00ff00 with a tolerance of 0" + ); + }); + + test("can compare colors with tolerance", () => { + expect("#ff0001").toBeSameColorAs("#ff0000", 0.1); + expect(() => expect("#ff0000").toBeSameColorAs("#ff00f0", 0.1)).toThrow( + "Expected #ff0000 to be equivalent to #ff00f0 with a tolerance of 0.1" + ); + }); + + test("can use not.toBeSameColor", () => { + expect("#ff0000").not.toBeSameColorAs("#00ff00"); + expect(() => expect("#ff0000").not.toBeSameColorAs("#ff0001", 0.1)).toThrow( + "Expected #ff0000 not to be equivalent to #ff0001 with a tolerance of 0.1" + ); + }); +}); + +describe("toHaveValue", () => { + test("supports text inputs and checkbox", () => { + setDom(` +
+ + +
+ `); + + expect(".text-input").toHaveValue("hello"); + expect(".checkbox-input").toHaveValue(true); + + // Note: it would be really annoying to test the full message because of jest pretty printing with colors/newlines + expect(() => expect(".text-input").toHaveValue("world")).toThrowErrorMatchingSnapshot(); + }); + + test("Can use not.toHaveValue", () => { + setDom(``); + + expect(".text-input").not.toHaveValue("world"); + + expect(() => expect(".text-input").not.toHaveValue("hello")).toThrowErrorMatchingSnapshot(); + }); +}); + +describe("toHaveText", () => { + test("matches exact text content", () => { + setDom('
Exact text
'); + + expect(".label").toHaveText("Exact text"); + expect(() => expect(".label").toHaveText("Other text")).toThrowErrorMatchingSnapshot(); + }); + + test("Can use not.toHaveText", () => { + setDom('
Exact text
'); + + expect(".label").not.toHaveText("Other text"); + expect(() => expect(".label").not.toHaveText("Exact text")).toThrowErrorMatchingSnapshot(); + }); +}); + +describe("toHaveCount", () => { + test("counts matching elements", () => { + setDom('
'); + + expect(".item").toHaveCount(2); + expect(() => expect(".item").toHaveCount(1)).toThrowErrorMatchingSnapshot(); + }); + + test("can use not.toHaveCount", () => { + setDom('
'); + + expect(".item").not.toHaveCount(1); + expect(() => expect(".item").not.toHaveCount(2)).toThrowErrorMatchingSnapshot(); + }); +}); + +describe("toHaveClass", () => { + test("can match an element classes", () => { + setDom('
'); + + expect(".box").toHaveClass("alpha"); + expect(".box").toHaveClass("beta"); + expect(() => expect(".box").toHaveClass("gamma")).toThrowErrorMatchingSnapshot(); + }); + + test("can use not.toHaveClass", () => { + setDom('
'); + + expect(".box").not.toHaveClass("gamma"); + expect(() => expect(".box").not.toHaveClass("alpha")).toThrowErrorMatchingSnapshot(); + }); +}); + +describe("toHaveAttribute", () => { + test("matches attribute values", () => { + setDom(''); + + expect(".action").toHaveAttribute("aria-label", "Confirm"); + expect(() => + expect(".action").toHaveAttribute("aria-label", "Cancel") + ).toThrowErrorMatchingSnapshot(); + }); + + test("can use not.toHaveAttribute", () => { + setDom(''); + + expect(".action").not.toHaveAttribute("aria-label", "Cancel"); + expect(() => + expect(".action").not.toHaveAttribute("aria-label", "Confirm") + ).toThrowErrorMatchingSnapshot(); + }); +}); + +describe("toHaveStyle", () => { + test("compares inline styles and normalizes rgb colors", () => { + setDom('
'); + + expect(".styled").toHaveStyle({ color: "#FF0000", display: "block" }); + expect(() => + expect(".styled").toHaveStyle({ color: "#00FF00" }) + ).toThrowErrorMatchingSnapshot(); + }); + + test("can use not.toHaveStyle", () => { + setDom('
'); + + expect(".styled").not.toHaveStyle({ color: "#00FF00" }); + expect(() => + expect(".styled").not.toHaveStyle({ display: "block" }) + ).toThrowErrorMatchingSnapshot(); + }); +}); + +describe("toBeCancelledBecause", () => { + test("matches cancelled dispatch reasons", () => { + const cancelled = new DispatchResult(CommandResult.CancelledForUnknownReason); + + expect(cancelled).toBeCancelledBecause(CommandResult.CancelledForUnknownReason); + expect(() => + expect(cancelled).toBeCancelledBecause(CommandResult.WillRemoveExistingMerge) + ).toThrowErrorMatchingSnapshot(); + }); + + test("can use not.toBeCancelledBecause", () => { + const cancelled = new DispatchResult(CommandResult.CancelledForUnknownReason); + + expect(cancelled).not.toBeCancelledBecause(CommandResult.WillRemoveExistingMerge); + expect(() => + expect(cancelled).not.toBeCancelledBecause(CommandResult.CancelledForUnknownReason) + ).toThrow( + "The command should not have been cancelled because of reason CancelledForUnknownReason" + ); + }); +}); + +describe("toBeSuccessfullyDispatched", () => { + test("matches successful dispatch results", () => { + expect(DispatchResult.Success).toBeSuccessfullyDispatched(); + expect(() => + expect( + new DispatchResult(CommandResult.CancelledForUnknownReason) + ).toBeSuccessfullyDispatched() + ).toThrowErrorMatchingSnapshot(); + }); + + test("can use not.toBeSuccessfullyDispatched", () => { + expect( + new DispatchResult(CommandResult.CancelledForUnknownReason) + ).not.toBeSuccessfullyDispatched(); + expect(() => + expect(DispatchResult.Success).not.toBeSuccessfullyDispatched() + ).toThrowErrorMatchingSnapshot(); + }); +}); + +describe("toExport", () => { + test("ignores the revision id and compares exported data", () => { + const model = createModelFromGrid({ A1: "42" }); + const expected = model.exportData() as any; + + expected.revisionId = "random-id"; + + expect(model).toExport(expected); + expect(() => expect(model).toExport(new Model().exportData())).toThrowErrorMatchingSnapshot(); + }); + + test("can use not.toExport", () => { + const model = createModelFromGrid({ A1: "42" }); + const expected = model.exportData() as any; + expected.revisionId = "random-id"; + + expect(model).not.toExport({ ...expected, sheets: [] }); + expect(() => expect(model).not.toExport(expected)).toThrowErrorMatchingSnapshot(); + }); +}); + +describe("toHaveSynchronizedValue", () => { + test("compares the callback result across models", () => { + const { network, alice, bob } = setupCollaborativeEnv(); + network.concurrent(() => setCellContent(alice, "A1", "=1+1")); + + expect([alice, bob]).toHaveSynchronizedValue((model) => getCellContent(model, "A1"), "2"); + expect(() => + expect([alice, new Model()]).toHaveSynchronizedValue( + (model) => getCellContent(model, "A1"), + "1" + ) + ).toThrowErrorMatchingSnapshot(); + }); + + test("cannot use not.toHaveSynchronizedValue, as it does not make sense", () => { + const { alice, bob } = setupCollaborativeEnv(); + expect(() => + expect([alice, bob]).not.toHaveSynchronizedValue((model) => getCellContent(model, "A1"), "1") + ).toThrow("not.toHaveSynchronizedValue is not supported"); + }); +}); + +describe("toHaveSynchronizedEvaluation", () => { + test("compares evaluated cells across models", () => { + const { network, alice, bob } = setupCollaborativeEnv(); + network.concurrent(() => setCellContent(alice, "A1", "=1+1")); + + expect([alice, bob]).toHaveSynchronizedEvaluation(); + expect(() => + expect([alice, new Model()]).toHaveSynchronizedEvaluation() + ).toThrowErrorMatchingSnapshot(); + }); + + test("cannot use not.toHaveSynchronizedEvaluation, as it does not make sense", () => { + const { alice, bob } = setupCollaborativeEnv(); + expect(() => expect([alice, bob]).not.toHaveSynchronizedEvaluation()).toThrow( + "not.toHaveSynchronizedEvaluation is not supported" + ); + }); +}); + +describe("toHaveSynchronizedExportedData", () => { + test("compares exported workbook data", () => { + const { network, alice, bob } = setupCollaborativeEnv(); + network.concurrent(() => setCellContent(alice, "A1", "42")); + expect([alice, bob]).toHaveSynchronizedExportedData(); + expect(() => + expect([alice, new Model()]).toHaveSynchronizedExportedData() + ).toThrowErrorMatchingSnapshot(); + }); + + test("cannot use not.toHaveSynchronizedExportedData, as it does not make sense", () => { + const { alice, bob } = setupCollaborativeEnv(); + expect(() => expect([alice, bob]).not.toHaveSynchronizedExportedData()).toThrow( + "not.toHaveSynchronizedExportedData is not supported" + ); + }); +}); diff --git a/tests/setup/jest_extend.ts b/tests/setup/jest_extend.ts index 330726ba71..ffc9bc8ac9 100644 --- a/tests/setup/jest_extend.ts +++ b/tests/setup/jest_extend.ts @@ -68,32 +68,29 @@ expect.extend({ toMatchImageSnapshot, toExport(model: Model, expected: any) { const exportData = model.exportData(); - if ( - !this.equals(exportData, { ...expected, revisionId: expect.any(String) }, [ - this.utils.iterableEquality, - ]) - ) { - return { - pass: !!this.isNot, - message: () => - `Diff: ${this.utils.printDiffOrStringify( - expected, - exportData, - "Expected", - "Received", - false - )}`, - }; - } - return { pass: !this.isNot, message: () => "" }; + const pass = this.equals(exportData, { ...expected, revisionId: expect.any(String) }, [ + this.utils.iterableEquality, + ]); + const message = () => + `Diff: ${this.utils.printDiffOrStringify( + expected, + exportData, + pass ? "Not expected" : "Expected", + "Received", + false + )}`; + return { pass, message }; }, toHaveSynchronizedValue(users: Model[], callback: (model: Model) => any, expected: any) { + if (this.isNot) { + throw new Error("not.toHaveSynchronizedValue is not supported"); + } for (const user of users) { const result = callback(user); if (!this.equals(result, expected, [this.utils.iterableEquality])) { const userId = user.getters.getCurrentClient().name; return { - pass: !!this.isNot, + pass: false, message: () => `${userId} does not have the expected value: \nReceived: ${this.utils.printReceived( result @@ -104,6 +101,9 @@ expect.extend({ return { pass: !this.isNot, message: () => "" }; }, toHaveSynchronizedEvaluation(users: Model[]) { + if (this.isNot) { + throw new Error("not.toHaveSynchronizedEvaluation is not supported"); + } for (let i = 0; i < users.length - 1; i++) { const a = users[i]; const b = users[i + 1]; @@ -117,7 +117,7 @@ expect.extend({ const prettyValuesUserA = getPrettyEvaluatedCells(a, sheetId, sheetZone); const prettyValuesUserB = getPrettyEvaluatedCells(b, sheetId, sheetZone); return { - pass: !!this.isNot, + pass: false, message: () => `${clientA} and ${clientB} are not synchronized: \n${this.utils.printDiffOrStringify( prettyValuesUserA, @@ -133,6 +133,9 @@ expect.extend({ return { pass: !this.isNot, message: () => "" }; }, toHaveSynchronizedExportedData(users: Model[]) { + if (this.isNot) { + throw new Error("not.toHaveSynchronizedExportedData is not supported"); + } for (let i = 0; i < users.length - 1; i++) { const a = users[i]; const b = users[i + 1]; @@ -142,7 +145,7 @@ expect.extend({ const clientA = a.getters.getCurrentClient().id; const clientB = b.getters.getCurrentClient().id; return { - pass: !!this.isNot, + pass: false, message: () => `${clientA} and ${clientB} are not synchronized: \n${this.utils.printDiffOrStringify( exportA, @@ -188,13 +191,12 @@ CancelledReasons: ${this.utils.printReceived(dispatchResult.reasons)} return { pass, message }; }, toBeBetween(received: number, lower: number, upper: number) { - if (received < lower || received > upper) { - return { - pass: false, - message: () => `Expected ${received} to be between ${lower} and ${upper}`, - }; - } - return { pass: true, message: () => "" }; + const pass = received >= lower && received <= upper; + return { + pass, + message: () => + `Expected ${received} ${pass ? "not " : ""}to be between ${lower} and ${upper}`, + }; }, toBeSameColorAs(received: string, expected: string, tolerance: number = 0) { let pass = false; @@ -204,13 +206,10 @@ CancelledReasons: ${this.utils.printReceived(dispatchResult.reasons)} pass = isSameColor(received, expected, tolerance); } const message = () => - pass - ? "" - : `Expected ${received} to be equivalent to ${expected} with a tolerance of ${tolerance}`; - return { - pass, - message, - }; + `Expected ${received}${ + pass ? " not" : "" + } to be equivalent to ${expected} with a tolerance of ${tolerance}`; + return { pass, message }; }, toHaveValue(target: DOMTarget, expectedValue: string | boolean) { const element = getTarget(target); @@ -223,20 +222,21 @@ CancelledReasons: ${this.utils.printReceived(dispatchResult.reasons)} (element.type === "checkbox" || element.type === "radio") ? element.checked : element.value; - if (value !== expectedValue) { - return { - pass: false, - message: () => - `expect(target).toHaveValue(expected);\n\n${this.utils.printDiffOrStringify( - expectedValue, - value, - "Expected value", - "Received value", - false - )}`, - }; - } - return { pass: true, message: () => "" }; + + const pass = value === expectedValue; + const message = () => { + const diff = this.utils.printDiffOrStringify( + expectedValue, + value, + pass ? "Unexpected value" : "Expected value", + "Received value", + false + ); + return pass + ? `expect(target).not.toHaveValue(expected);\n\n${diff}` + : `expect(target).toHaveValue(expected);\n\n${diff}`; + }; + return { pass, message }; }, toHaveText(target: DOMTarget, expectedText: string) { const element = getTarget(target); @@ -245,37 +245,37 @@ CancelledReasons: ${this.utils.printReceived(dispatchResult.reasons)} return { pass: false, message: () => message }; } const text = element.textContent; - if (text !== expectedText) { - return { - pass: false, - message: () => - `expect(target).toHaveText(expected);\n\n${this.utils.printDiffOrStringify( - expectedText, - text, - "Expected text", - "Received text", - false - )}`, - }; - } - return { pass: true, message: () => "" }; + const pass = text === expectedText; + const message = () => { + const diff = this.utils.printDiffOrStringify( + expectedText, + text, + pass ? "Unexpected text" : "Expected text", + "Received text", + false + ); + return pass + ? `expect(target).not.toHaveText(expected);\n\n${diff}` + : `expect(target).toHaveText(expected);\n\n${diff}`; + }; + return { pass, message }; }, toHaveCount(selector: string, expectedCount: number) { const elements = document.querySelectorAll(selector); - if (elements.length !== expectedCount) { - return { - pass: false, - message: () => - `expect("${selector}").toHaveCount(expected);\n\n${this.utils.printDiffOrStringify( - expectedCount, - elements.length, - "Expected", - "Received", - false - )}`, - }; - } - return { pass: true, message: () => "" }; + const pass = elements.length === expectedCount; + const message = () => { + const diff = this.utils.printDiffOrStringify( + expectedCount, + elements.length, + pass ? "Unexpected count" : "Expected count", + "Received", + false + ); + return pass + ? `expect("${selector}").not.toHaveCount(expected);\n\n${diff}` + : `expect("${selector}").toHaveCount(expected);\n\n${diff}`; + }; + return { pass, message }; }, toHaveClass(target: DOMTarget, expectedClass: string) { const element = getTarget(target); @@ -285,24 +285,16 @@ CancelledReasons: ${this.utils.printReceived(dispatchResult.reasons)} } const pass = element.classList.contains(expectedClass); const message = () => { - if (this.isNot && pass) { - return `expect(target).not.toHaveClass(expected);\n\n${this.utils.printDiffOrStringify( - expectedClass, - element.className, - "Unexpected class", - "Received class", - false - )}`; - } else if (!pass) { - return `expect(target).toHaveClass(expected);\n\n${this.utils.printDiffOrStringify( - expectedClass, - element.className, - "Expected class", - "Received class", - false - )}`; - } - return ""; + const diff = this.utils.printDiffOrStringify( + expectedClass, + element.className, + pass ? "Unexpected class" : "Expected class", + "Received classes", + false + ); + return pass + ? `expect(target).not.toHaveClass(expected);\n\n${diff}` + : `expect(target).toHaveClass(expected);\n\n${diff}`; }; return { pass, message }; }, @@ -313,16 +305,18 @@ CancelledReasons: ${this.utils.printReceived(dispatchResult.reasons)} return { pass: false, message: () => message }; } const pass = element.getAttribute(attribute) === expectedValue; - const message = () => - pass - ? "" - : `expect(target).toHaveAttribute(attribute, expected);\n\n${this.utils.printDiffOrStringify( - expectedValue, - element.getAttribute(attribute), - "Expected value", - "Received value", - false - )}`; + const message = () => { + const diff = this.utils.printDiffOrStringify( + expectedValue, + element.getAttribute(attribute), + pass ? "Unexpected attribute value" : "Expected attribute value", + "Received value", + false + ); + return pass + ? `expect(target).not.toHaveAttribute(${attribute}, expected);\n\n${diff}` + : `expect(target).toHaveAttribute(${attribute}, expected);\n\n${diff}`; + }; return { pass, message }; }, toHaveStyle(target: DOMTarget, expectedStyle: Record) { @@ -339,16 +333,18 @@ CancelledReasons: ${this.utils.printReceived(dispatchResult.reasons)} } } const pass = this.equals(receivedStyle, expectedStyle, [this.utils.iterableEquality]); - const message = () => - pass - ? "" - : `expect(target).toHaveStyle(expected);\n\n${this.utils.printDiffOrStringify( - expectedStyle, - receivedStyle, - "Expected style", - "Received style", - false - )}`; + const message = () => { + const diff = this.utils.printDiffOrStringify( + expectedStyle, + receivedStyle, + pass ? "Unexpected style" : "Expected style", + "Received style", + false + ); + return pass + ? `expect(target).not.toHaveStyle(expected);\n\n${diff}` + : `expect(target).toHaveStyle(expected);\n\n${diff}`; + }; return { pass, message }; }, });