fix: Correct existing subtypes test coverage; add test coverage for

subtype actions; and a subtype action fix.
This commit is contained in:
Daniel J. Geiger 2023-01-02 12:43:19 -06:00
parent 13d69d8cef
commit 67fb3210ab
3 changed files with 143 additions and 62 deletions

View File

@ -158,17 +158,35 @@ const isActionEnabled: EnableFn = function (elements, appState, actionName) {
}
return !isForSubtype(subtype, actionName, false);
}))) ||
// Or is there an ExcalidrawElement without a subtype which would
// disable this action if it had a subtype?
// Or can we find an ExcalidrawElement without a valid subtype
// which would disable this action if it had a valid subtype?
chosen.some((el) => {
const e = hasBoundTextElement(el) ? getBoundTextElement(el)! : el;
return parentTypeMap.some(
(value) =>
value.parentType === e.type &&
e.subtype === undefined &&
disabledActionMap
.find((val) => val.subtype === value.subtype)!
.actions.includes(actionName),
!isValidSubtype(e.subtype, e.type) &&
isForSubtype(value.subtype, actionName, false),
);
}) ||
chosen.some((el) => {
const e = hasBoundTextElement(el) ? getBoundTextElement(el)! : el;
return (
// Would the subtype of e by inself disable this action?
isForSubtype(e.subtype, actionName, false) &&
// Can we find an ExcalidrawElement which could have the same subtype
// as e but whose subtype does not disable this action?
chosen.some((el) => {
const e2 = hasBoundTextElement(el) ? getBoundTextElement(el)! : el;
return (
// Does e have a valid subtype whose parent types include the
// type of e2, and does the subtype of e2 not disable this action?
parentTypeMap
.filter((val) => val.subtype === e.subtype)
.some((val) => val.parentType === e2.type) &&
!isForSubtype(e2.subtype, actionName, false)
);
})
);
})
);

View File

@ -43,6 +43,10 @@ export class API {
static addSubtype = (record: SubtypeRecord, subtypePrepFn: SubtypePrepFn) => {
const prep = prepareSubtype(record, subtypePrepFn);
if (prep.actions) {
h.app.actionManager.registerAll(prep.actions);
h.app.actionManager.registerActionGuards();
}
return prep;
};

View File

@ -16,7 +16,7 @@ import { render } from "./test-utils";
import { API } from "./helpers/api";
import ExcalidrawApp from "../excalidraw-app";
import { FontString, Theme } from "../element/types";
import { ExcalidrawElement, FontString, Theme } from "../element/types";
import { createIcon, iconFillColor } from "../components/icons";
import { SubtypeButton } from "../components/SubtypeButton";
import { registerAuxLangData } from "../i18n";
@ -24,6 +24,7 @@ import { getFontString, getShortcutKey } from "../utils";
import * as textElementUtils from "../element/textElement";
import { isTextElement } from "../element";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { Action } from "../actions/types";
import { AppState } from "../types";
import { getShortcutFromShortcutName } from "../actions/shortcuts";
@ -58,10 +59,26 @@ const testSubtypeIcon = ({ theme }: { theme: Theme }) =>
{ width: 40, height: 20, mirror: true },
);
const TEST_ACTION = "testAction";
const TEST_DISABLE1 = "changeSloppiness";
const TEST_DISABLE3 = "changeRoundness";
const test1: SubtypeRecord = {
subtype: "test",
parents: ["line", "arrow", "rectangle", "diamond", "ellipse"],
disabledNames: ["changeSloppiness"],
disabledNames: [TEST_DISABLE1],
actionNames: [TEST_ACTION],
};
const testAction: Action = {
name: TEST_ACTION,
trackEvent: false,
perform: (elements, appState) => {
return {
elements,
commitToHistory: false,
};
},
};
const test1Button = SubtypeButton(
@ -89,6 +106,7 @@ const test3: SubtypeRecord = {
testShortcut: [getShortcutKey("Shift+T")],
},
alwaysEnabledNames: ["test3Always"],
disabledNames: [TEST_DISABLE3],
};
const test3Button = SubtypeButton(
@ -129,7 +147,7 @@ const prepareTest1Subtype = function (
addLangData(fallbackLangData, getLangData);
registerAuxLangData(fallbackLangData, getLangData);
const actions = [test1Button];
const actions = [testAction, test1Button];
actions.forEach((action) => addSubtypeAction(action));
return { actions, methods };
@ -207,6 +225,7 @@ const { h } = window;
describe("subtype registration", () => {
it("should check for invalid subtype or parents", async () => {
await render(<ExcalidrawApp />, {});
// Define invalid subtype records
const null1 = {} as SubtypeRecord;
const null2 = { subtype: "" } as SubtypeRecord;
@ -226,7 +245,7 @@ describe("subtype registration", () => {
it("should return subtype actions and methods correctly", async () => {
// Check initial registration works
let prep1 = API.addSubtype(test1, prepareTest1Subtype);
expect(prep1.actions).toStrictEqual([test1Button]);
expect(prep1.actions).toStrictEqual([testAction, test1Button]);
expect(prep1.methods).toStrictEqual({ clean: cleanTestElementUpdate });
// Check repeat registration fails
prep1 = API.addSubtype(test1, prepareNullSubtype);
@ -313,46 +332,28 @@ describe("subtypes", () => {
expect(subtypeCollides(test1.subtype, [test3.subtype])).toBe(true);
});
it("should apply to ExcalidrawElements", async () => {
await render(<ExcalidrawApp />, {
localStorageData: {
elements: [
API.createElement({ type: "line", id: "A", subtype: test1.subtype }),
API.createElement({ type: "arrow", id: "B", subtype: test1.subtype }),
API.createElement({
type: "rectangle",
id: "C",
subtype: test1.subtype,
}),
API.createElement({
type: "diamond",
id: "D",
subtype: test1.subtype,
}),
API.createElement({
type: "ellipse",
id: "E",
subtype: test1.subtype,
}),
],
},
});
h.elements.forEach((el) => expect(el.subtype).toBe(test1.subtype));
const elements = [
API.createElement({ type: "line", id: "A", subtype: test1.subtype }),
API.createElement({ type: "arrow", id: "B", subtype: test1.subtype }),
API.createElement({ type: "rectangle", id: "C", subtype: test1.subtype }),
API.createElement({ type: "diamond", id: "D", subtype: test1.subtype }),
API.createElement({ type: "ellipse", id: "E", subtype: test1.subtype }),
];
await render(<ExcalidrawApp />, { localStorageData: { elements } });
elements.forEach((el) => expect(el.subtype).toBe(test1.subtype));
});
it("should enforce prop value restrictions", async () => {
await render(<ExcalidrawApp />, {
localStorageData: {
elements: [
API.createElement({
type: "line",
id: "A",
subtype: test1.subtype,
roughness: 1,
}),
API.createElement({ type: "line", id: "B", roughness: 1 }),
],
},
});
h.elements.forEach((el) => {
const elements = [
API.createElement({
type: "line",
id: "A",
subtype: test1.subtype,
roughness: 1,
}),
API.createElement({ type: "line", id: "B", roughness: 1 }),
];
await render(<ExcalidrawApp />, { localStorageData: { elements } });
elements.forEach((el) => {
if (el.subtype === test1.subtype) {
expect(el.roughness).toBe(0);
} else {
@ -401,19 +402,16 @@ describe("subtypes", () => {
});
it("should call custom text methods", async () => {
const testString = "A quick brown fox jumps over the lazy dog.";
await render(<ExcalidrawApp />, {
localStorageData: {
elements: [
API.createElement({
type: "text",
id: "A",
subtype: test2.subtype,
text: testString,
fontSize: FONTSIZE,
}),
],
},
});
const elements = [
API.createElement({
type: "text",
id: "A",
subtype: test2.subtype,
text: testString,
fontSize: FONTSIZE,
}),
];
await render(<ExcalidrawApp />, { localStorageData: { elements } });
const mockMeasureText = (
text: string,
font: FontString,
@ -441,7 +439,7 @@ describe("subtypes", () => {
.spyOn(textElementUtils, "measureText")
.mockImplementation(mockMeasureText);
h.elements.forEach((el) => {
elements.forEach((el) => {
if (isTextElement(el)) {
// First test with `ExcalidrawTextElement.text`
const metrics = textElementUtils.measureTextElement(el);
@ -596,3 +594,64 @@ describe("subtypes", () => {
expect(subtypes.customData![test3.subtype]).toBeUndefined();
});
});
describe("subtype actions", () => {
let elements: ExcalidrawElement[];
beforeEach(async () => {
elements = [
API.createElement({ type: "line", id: "A", subtype: test1.subtype }),
API.createElement({ type: "line", id: "B" }),
API.createElement({ type: "line", id: "C", subtype: test3.subtype }),
API.createElement({ type: "text", id: "D", subtype: test3.subtype }),
];
await render(<ExcalidrawApp />, { localStorageData: { elements } });
});
it("should apply to elements with their subtype", async () => {
h.setState({ selectedElementIds: { A: true } });
const am = h.app.actionManager;
expect(am.isActionEnabled(elements, h.state, TEST_ACTION)).toBe(true);
expect(am.isActionEnabled(elements, h.state, TEST_DISABLE1)).toBe(false);
});
it("should apply to elements without a subtype", async () => {
h.setState({ selectedElementIds: { B: true } });
const am = h.app.actionManager;
expect(am.isActionEnabled(elements, h.state, TEST_ACTION)).toBe(false);
expect(am.isActionEnabled(elements, h.state, TEST_DISABLE1)).toBe(true);
});
it("should apply to elements with and without their subtype", async () => {
h.setState({ selectedElementIds: { A: true, B: true } });
const am = h.app.actionManager;
expect(am.isActionEnabled(elements, h.state, TEST_ACTION)).toBe(true);
expect(am.isActionEnabled(elements, h.state, TEST_DISABLE1)).toBe(true);
});
it("should apply to elements with a different subtype", async () => {
h.setState({ selectedElementIds: { C: true, D: true } });
const am = h.app.actionManager;
expect(am.isActionEnabled(elements, h.state, TEST_ACTION)).toBe(false);
expect(am.isActionEnabled(elements, h.state, TEST_DISABLE1)).toBe(true);
});
it("should apply to like types with varying subtypes", async () => {
h.setState({ selectedElementIds: { A: true, C: true } });
const am = h.app.actionManager;
expect(am.isActionEnabled(elements, h.state, TEST_ACTION)).toBe(true);
expect(am.isActionEnabled(elements, h.state, TEST_DISABLE1)).toBe(true);
});
it("should apply to non-like types with varying subtypes", async () => {
h.setState({ selectedElementIds: { A: true, D: true } });
const am = h.app.actionManager;
expect(am.isActionEnabled(elements, h.state, TEST_ACTION)).toBe(true);
expect(am.isActionEnabled(elements, h.state, TEST_DISABLE1)).toBe(false);
});
it("should apply to like/non-like types with varying subtypes", async () => {
h.setState({ selectedElementIds: { A: true, B: true, D: true } });
const am = h.app.actionManager;
expect(am.isActionEnabled(elements, h.state, TEST_ACTION)).toBe(true);
expect(am.isActionEnabled(elements, h.state, TEST_DISABLE1)).toBe(true);
});
it("should apply to the correct parent type", async () => {
const am = h.app.actionManager;
h.setState({ selectedElementIds: { A: true, C: true } });
expect(am.isActionEnabled(elements, h.state, TEST_DISABLE3)).toBe(true);
h.setState({ selectedElementIds: { A: true, D: true } });
expect(am.isActionEnabled(elements, h.state, TEST_DISABLE3)).toBe(true);
});
});