Files
enterprise-addons/web_gantt/static/tests/gantt_dependency.test.js
T

802 lines
26 KiB
JavaScript

import { beforeEach, describe, expect, queryOne, test } from "@odoo/hoot";
import { hover, pointerDown, queryAll, queryFirst, queryRect, resize } from "@odoo/hoot-dom";
import { advanceFrame, animationFrame, mockDate, runAllTimers } from "@odoo/hoot-mock";
import {
contains,
defineModels,
fields,
findComponent,
models,
onRpc,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import {
clickConnectorButton,
getConnector,
getConnectorMap,
getConnectorStroke,
} from "@web_gantt/../tests/gantt_dependency_helpers";
import { COLORS } from "@web_gantt/gantt_connector";
import {
CLASSES,
SELECTORS,
getPill,
getPillWrapper,
mountGanttView,
dragPill, resizePill,
} from "./web_gantt_test_helpers";
import { GanttRenderer } from "@web_gantt/gantt_renderer";
/** @typedef {import("@web_gantt/gantt_renderer").ConnectorProps} ConnectorProps */
/** @typedef {import("@web_gantt/gantt_renderer").PillId} PillId */
/**
* @typedef {`[${ResId},${ResId},${ResId},${ResId}]`} ConnectorTaskIds
* In the following order: [masterTaskId, masterTaskUserId, taskId, taskUserId]
*/
/** @typedef {number | false} ResId */
const ganttViewParams = {
resModel: "project.task",
arch: /* xml */ `<gantt date_start="planned_date_begin" date_stop="date_deadline" dependency_field="depend_on_ids" color="color" />`,
groupBy: ["user_ids"],
};
let nextColor = 1;
class ProjectTask extends models.Model {
_name = "project.task";
name = fields.Char();
planned_date_begin = fields.Datetime({ string: "Start Date" });
date_deadline = fields.Datetime({ string: "Stop Date" });
user_ids = fields.Many2many({ string: "Assignees", relation: "res.users" });
allow_task_dependencies = fields.Boolean({ default: true });
depend_on_ids = fields.One2many({ string: "Depends on", relation: "project.task" });
display_warning_dependency_in_gantt = fields.Boolean({ default: true });
color = fields.Integer({ default: () => nextColor++ });
_records = [
{
id: 1,
name: "Task 1",
planned_date_begin: "2021-10-11 18:30:00",
date_deadline: "2021-10-11 19:29:59",
user_ids: [1],
depend_on_ids: [],
},
{
id: 2,
name: "Task 2",
planned_date_begin: "2021-10-12 11:30:00",
date_deadline: "2021-10-12 12:29:59",
user_ids: [1, 3],
depend_on_ids: [1],
},
{
id: 3,
name: "Task 3",
planned_date_begin: "2021-10-13 06:30:00",
date_deadline: "2021-10-13 07:29:59",
user_ids: [],
depend_on_ids: [2],
},
{
id: 4,
name: "Task 4",
planned_date_begin: "2021-10-14 22:30:00",
date_deadline: "2021-10-14 23:29:59",
user_ids: [2, 3],
depend_on_ids: [2],
},
{
id: 5,
name: "Task 5",
planned_date_begin: "2021-10-15 01:53:10",
date_deadline: "2021-10-15 02:34:34",
user_ids: [],
depend_on_ids: [],
},
{
id: 6,
name: "Task 6",
planned_date_begin: "2021-10-16 23:00:00",
date_deadline: "2021-10-16 23:21:01",
user_ids: [1, 3],
depend_on_ids: [4, 5],
},
{
id: 7,
name: "Task 7",
planned_date_begin: "2021-10-17 10:30:12",
date_deadline: "2021-10-17 11:29:59",
user_ids: [1, 2, 3],
depend_on_ids: [6],
},
{
id: 8,
name: "Task 8",
planned_date_begin: "2021-10-18 06:30:12",
date_deadline: "2021-10-18 07:29:59",
user_ids: [1, 3],
depend_on_ids: [7],
},
{
id: 9,
name: "Task 9",
planned_date_begin: "2021-10-19 06:30:12",
date_deadline: "2021-10-19 07:29:59",
user_ids: [2],
depend_on_ids: [8],
},
{
id: 10,
name: "Task 10",
planned_date_begin: "2021-10-19 06:30:12",
date_deadline: "2021-10-19 07:29:59",
user_ids: [2],
depend_on_ids: [],
},
{
id: 11,
name: "Task 11",
planned_date_begin: "2021-10-18 06:30:12",
date_deadline: "2021-10-18 07:29:59",
user_ids: [2],
depend_on_ids: [10],
},
{
id: 12,
name: "Task 12",
planned_date_begin: "2021-10-18 06:30:12",
date_deadline: "2021-10-19 07:29:59",
user_ids: [2],
depend_on_ids: [],
},
{
id: 13,
name: "Task 13",
planned_date_begin: "2021-10-18 07:29:59",
date_deadline: "2021-10-20 07:29:59",
user_ids: [2],
depend_on_ids: [12],
},
];
}
class ResUsers extends models.Model {
_name = "res.users";
name = fields.Char();
_records = [
{ id: 1, name: "User 1" },
{ id: 2, name: "User 2" },
{ id: 3, name: "User 3" },
{ id: 4, name: "User 4" },
];
}
defineModels([ProjectTask, ResUsers]);
describe.current.tags("desktop");
beforeEach(() => mockDate("2021-10-10T08:00:00", +1));
test("Connectors are correctly computed and rendered.", async () => {
/**
* @type {Map<ConnectorTaskIds, keyof typeof COLORS>}
* => Check that there is a connector between masterTaskId from group masterTaskUserId and taskId from group taskUserId with normal|error color.
*/
const testMap = new Map([
["[1,1,2,1]", "default"],
["[1,1,2,3]", "default"],
["[2,1,3,false]", "default"],
["[2,3,3,false]", "default"],
["[2,1,4,2]", "default"],
["[2,3,4,3]", "default"],
["[4,2,6,1]", "default"],
["[4,3,6,3]", "default"],
["[5,false,6,1]", "default"],
["[5,false,6,3]", "default"],
["[6,1,7,1]", "default"],
["[6,1,7,2]", "default"],
["[6,3,7,2]", "default"],
["[6,3,7,3]", "default"],
["[7,1,8,1]", "default"],
["[7,2,8,1]", "default"],
["[7,2,8,3]", "default"],
["[7,3,8,3]", "default"],
["[8,1,9,2]", "default"],
["[8,3,9,2]", "default"],
["[10,2,11,2]", "error"],
["[12,2,13,2]", "warning"],
]);
const view = await mountGanttView(ganttViewParams);
const renderer = findComponent(view, (c) => c instanceof GanttRenderer);
const connectorMap = getConnectorMap(renderer);
for (const [testKey, colorCode] of testMap.entries()) {
const [masterTaskId, masterTaskUserId, taskId, taskUserId] = JSON.parse(testKey);
expect(connectorMap.has(testKey)).toBe(true, {
message: `There should be a connector between task ${masterTaskId} from group user ${masterTaskUserId} and task ${taskId} from group user ${taskUserId}.`,
});
const connector = connectorMap.get(testKey);
expect(getConnector(connector.id)).toHaveCount(1);
expect(getConnectorStroke(connector.id)).toHaveAttribute("stroke", COLORS[colorCode].color);
}
expect(testMap).toHaveLength(connectorMap.size);
expect(SELECTORS.connector).toHaveCount(testMap.size);
});
test("Connectors are correctly rendered.", async () => {
patchWithCleanup(GanttRenderer.prototype, {
shouldRenderRecordConnectors(record) {
return record.id !== 1;
},
});
ProjectTask._records = [
{
id: 1,
name: "Task 1",
planned_date_begin: "2021-10-11 18:30:00",
date_deadline: "2021-10-11 19:29:59",
user_ids: [1],
depend_on_ids: [],
},
{
id: 2,
name: "Task 2",
planned_date_begin: "2021-10-12 11:30:00",
date_deadline: "2021-10-12 12:29:59",
user_ids: [1],
depend_on_ids: [1],
},
{
id: 3,
name: "Task 3",
planned_date_begin: "2021-10-13 06:30:00",
date_deadline: "2021-10-13 07:29:59",
user_ids: [],
depend_on_ids: [1, 2],
},
];
const view = await mountGanttView(ganttViewParams);
const renderer = findComponent(view, (c) => c instanceof GanttRenderer);
const connectorMap = getConnectorMap(renderer);
expect([...connectorMap.keys()]).toEqual(["[2,1,3,false]"], {
message: "The only rendered connector should be the one from task_id 2 to task_id 3",
});
});
test("Connectors are correctly computed and rendered when consolidation is active.", async () => {
ProjectTask._records = [
{
id: 1,
name: "Task 1",
planned_date_begin: "2021-10-11 18:30:00",
date_deadline: "2021-10-11 19:29:59",
user_ids: [1],
depend_on_ids: [],
},
{
id: 2,
name: "Task 2",
planned_date_begin: "2021-10-12 11:30:00",
date_deadline: "2021-10-12 12:29:59",
user_ids: [1, 3],
depend_on_ids: [1],
},
{
id: 3,
name: "Task 3",
planned_date_begin: "2021-10-13 06:30:00",
date_deadline: "2021-10-13 07:29:59",
user_ids: [],
depend_on_ids: [2],
},
{
id: 4,
name: "Task 4",
planned_date_begin: "2021-10-14 22:30:00",
date_deadline: "2021-10-14 23:29:59",
user_ids: [2, 3],
depend_on_ids: [2],
},
{
id: 5,
name: "Task 5",
planned_date_begin: "2021-10-15 01:53:10",
date_deadline: "2021-10-15 02:34:34",
user_ids: [],
depend_on_ids: [],
},
{
id: 6,
name: "Task 6",
planned_date_begin: "2021-10-16 23:00:00",
date_deadline: "2021-10-16 23:21:01",
user_ids: [1, 3],
depend_on_ids: [4, 5],
},
{
id: 7,
name: "Task 7",
planned_date_begin: "2021-10-17 10:30:12",
date_deadline: "2021-10-17 11:29:59",
user_ids: [1, 2, 3],
depend_on_ids: [6],
},
{
id: 8,
name: "Task 8",
planned_date_begin: "2021-10-18 06:30:12",
date_deadline: "2021-10-18 07:29:59",
user_ids: [1, 3],
depend_on_ids: [7],
},
{
id: 9,
name: "Task 9",
planned_date_begin: "2021-10-19 06:30:12",
date_deadline: "2021-10-19 07:29:59",
user_ids: [2],
depend_on_ids: [8],
},
{
id: 10,
name: "Task 10",
planned_date_begin: "2021-10-19 06:30:12",
date_deadline: "2021-10-19 07:29:59",
user_ids: [2],
depend_on_ids: [],
},
{
id: 11,
name: "Task 11",
planned_date_begin: "2021-10-18 06:30:12",
date_deadline: "2021-10-18 07:29:59",
user_ids: [2],
depend_on_ids: [10],
},
{
id: 12,
name: "Task 12",
planned_date_begin: "2021-10-18 06:30:12",
date_deadline: "2021-10-19 07:29:59",
user_ids: [2],
depend_on_ids: [],
},
{
id: 13,
name: "Task 13",
planned_date_begin: "2021-10-18 07:29:59",
date_deadline: "2021-10-20 07:29:59",
user_ids: [2],
depend_on_ids: [12],
},
];
await mountGanttView({
...ganttViewParams,
arch: /* xml */ `<gantt date_start="planned_date_begin" date_stop="date_deadline" default_range="month" dependency_field="depend_on_ids" consolidation_max="{'user_ids': 100 }"/>`,
});
// groups have been created of r
expect(".o_gantt_row_header.o_gantt_group.o_group_open").toHaveCount(4);
function getGroupRow(index) {
return queryAll(".o_gantt_row_header.o_gantt_group")[index];
}
expect(SELECTORS.connector).toHaveCount(22);
await contains(getGroupRow(1)).click();
expect(getGroupRow(1)).not.toHaveClass("o_group_open");
expect(SELECTORS.connector).toHaveCount(13);
await contains(getGroupRow(1)).click();
expect(SELECTORS.connector).toHaveCount(22);
await contains(getGroupRow(1)).click();
expect(SELECTORS.connector).toHaveCount(13);
await contains(getGroupRow(2)).click();
expect(SELECTORS.connector).toHaveCount(6);
await contains(getGroupRow(0)).click();
expect(SELECTORS.connector).toHaveCount(4);
await contains(getGroupRow(3)).click();
expect(SELECTORS.connector).toHaveCount(0);
});
test("Connector hovered state is triggered and color is set accordingly.", async () => {
await mountGanttView(ganttViewParams);
expect(getConnector(1)).not.toHaveClass(CLASSES.highlightedConnector);
expect(getConnectorStroke(1)).toHaveAttribute("stroke", COLORS.default.color);
await hover(getConnectorStroke(1));
await animationFrame();
expect(getConnector(1)).toHaveClass(CLASSES.highlightedConnector);
expect(getConnectorStroke(1)).toHaveAttribute("stroke", COLORS.default.highlightedColor);
});
test("Buttons are displayed when hovering a connector.", async () => {
await mountGanttView(ganttViewParams);
expect(queryAll(SELECTORS.connectorStrokeButton, { root: getConnector(1) })).toHaveCount(0);
await hover(getConnectorStroke(1));
await animationFrame();
expect(queryAll(SELECTORS.connectorStrokeButton, { root: getConnector(1) })).toHaveCount(1);
});
test("Buttons are displayed when hovering a connector after a pill has been hovered.", async () => {
await mountGanttView(ganttViewParams);
expect(queryAll(SELECTORS.connectorStrokeButton, { root: getConnector(1) })).toHaveCount(0);
await hover(getPill("Task 1"));
await animationFrame();
expect(queryAll(SELECTORS.connectorStrokeButton, { root: getConnector(1) })).toHaveCount(0);
await hover(getConnectorStroke(1));
await animationFrame();
await animationFrame();
expect(getConnector(1)).toHaveClass(CLASSES.highlightedConnector);
expect(queryAll(SELECTORS.connectorStrokeButton, { root: getConnector(1) })).toHaveCount(1);
});
test("Connector buttons: remove a dependency", async () => {
onRpc(({ method, model, args }) => {
if (model === "project.task" && ["web_gantt_reschedule", "write"].includes(method)) {
expect.step([method, args]);
return true;
}
});
await mountGanttView(ganttViewParams);
await clickConnectorButton(getConnector(1), "remove");
expect.verifySteps([["write", [[2], { depend_on_ids: [[3, 1, false]] }]]]);
});
test("Connectors are displayed behind pills, except on hover.", async () => {
const getZIndex = (el) => Number(getComputedStyle(queryFirst(el)).zIndex) || 0;
await mountGanttView(ganttViewParams);
expect(getZIndex(getPillWrapper("Task 2"))).toBeGreaterThan(getZIndex(getConnector(1)));
await hover(getConnectorStroke(1));
await animationFrame();
expect(getZIndex(getPillWrapper("Task 2"))).toBeLessThan(getZIndex(getConnector(1)));
});
test("Hovering a connector should not cause the connected pills to get highlighted.", async () => {
await mountGanttView(ganttViewParams);
expect(SELECTORS.highlightedPill).toHaveCount(0);
await hover(getConnectorStroke(1));
expect(SELECTORS.highlightedPill).toHaveCount(0);
});
test("Create a connector from the gantt view.", async () => {
onRpc("write", ({ args, method }) => expect.step([method, args]));
await mountGanttView(ganttViewParams);
// Explicitly shows the connector creator wrapper since its "display: none"
// disappears on native CSS hover, which cannot be programatically emulated.
const rightWrapper = queryFirst(SELECTORS.connectorCreatorWrapper);
rightWrapper.classList.add("d-block");
await contains(
`${SELECTORS.connectorCreatorWrapper} ${SELECTORS.connectorCreatorBullet}:first`
).dragAndDrop(getPill("Task 2"));
expect.verifySteps([["write", [[2], { depend_on_ids: [[4, 3, false]] }]]]);
});
test("Create a connector from the gantt view: going fast", async () => {
await mountGanttView({
...ganttViewParams,
domain: [["id", "in", [1, 3]]],
});
// Explicitly shows the connector creator wrapper since its "display: none"
// disappears on native CSS hover, which cannot be programatically emulated.
const rightWrapper = queryFirst(SELECTORS.connectorCreatorWrapper, {
root: getPillWrapper("Task 1"),
});
rightWrapper.classList.add("d-block");
const connectorBullet = queryFirst(SELECTORS.connectorCreatorBullet, { root: rightWrapper });
const bulletRect = queryRect(connectorBullet);
const initialPosition = {
x: Math.floor(bulletRect.left + bulletRect.width / 2), // floor to avoid sub-pixel positioning
y: Math.floor(bulletRect.top + bulletRect.height / 2), // floor to avoid sub-pixel positioning
};
await pointerDown(connectorBullet, {
position: { clientX: initialPosition.x, clientY: initialPosition.y },
});
// Here we simulate a fast move, using arbitrary values.
const currentPosition = {
x: Math.floor(initialPosition.x + 123), // floor to avoid sub-pixel positioning
y: Math.floor(initialPosition.y + 12), // floor to avoid sub-pixel positioning
};
await hover(SELECTORS.cellContainer, {
position: { clientX: currentPosition.x, clientY: currentPosition.y },
});
await animationFrame();
// Then we check that the connector stroke is correctly positioned.
expect(getConnectorStroke("new")).toHaveRect({
top: initialPosition.y,
right: currentPosition.x,
bottom: currentPosition.y,
left: initialPosition.x,
});
});
test("Connectors should be rendered if connected pill is not visible", async () => {
// We first need to bump all the ids for users 2, 3 and 4 to make them disappear.
for (const record of ResUsers._records.slice(1)) {
record.id += 1000;
}
for (const record of ProjectTask._records) {
record.user_ids = record.user_ids.map((id) => (id > 1 ? id + 1000 : id));
}
// Generate a lot of users so that the connectors are far beyond the visible
// viewport, hence generating fake extra pills to render the connectors.
for (let i = 0; i < 100; i++) {
const id = 100 + i;
ResUsers._records.push({ id, name: `User ${id}` });
ProjectTask._records.push({
id,
name: `Task ${id}`,
planned_date_begin: "2021-10-11 18:30:00",
date_deadline: "2021-10-11 19:29:59",
user_ids: [id],
depend_on_ids: [],
});
}
ProjectTask._records[12].user_ids = [199];
await mountGanttView(ganttViewParams);
expect(queryAll(SELECTORS.connector, { visible: true })).toHaveCount(13);
});
test("No display of resize handles when creating a connector", async () => {
await mountGanttView(ganttViewParams);
// Explicitly shows the connector creator wrapper since its "display: none"
// disappears on native CSS hover, which cannot be programatically emulated.
const rightWrapper = queryFirst(SELECTORS.connectorCreatorWrapper);
rightWrapper.classList.add("d-block");
// Creating a connector and hover another pill while dragging it
const { cancel, moveTo } = await contains(SELECTORS.connectorCreatorBullet, {
root: rightWrapper,
}).drag();
await moveTo(getPill("Task 2"));
expect(SELECTORS.resizeHandle).toHaveCount(0);
await cancel();
});
test("Renderer in connect mode when creating a connector", async () => {
await mountGanttView(ganttViewParams);
// Explicitly shows the connector creator wrapper since its "display: none"
// disappears on native CSS hover, which cannot be programatically emulated.
const rightWrapper = queryFirst(SELECTORS.connectorCreatorWrapper);
rightWrapper.classList.add("d-block");
// Creating a connector and hover another pill while dragging it
const { cancel, moveTo } = await contains(SELECTORS.connectorCreatorBullet, {
root: rightWrapper,
}).drag();
await moveTo(getPill("Task 2"));
expect(SELECTORS.renderer).toHaveClass("o_connect");
await cancel();
});
test("Connector creators of initial pill are highlighted when creating a connector", async () => {
await mountGanttView(ganttViewParams);
// Explicitly shows the connector creator wrapper since its "display: none"
// disappears on native CSS hover, which cannot be programatically emulated.
const rightWrapper = queryFirst`${SELECTORS.pillWrapper} ${SELECTORS.connectorCreatorWrapper}`;
rightWrapper.classList.add("d-block");
// Creating a connector and hover another pill while dragging it
const { cancel, moveTo } = await contains(SELECTORS.connectorCreatorBullet, {
root: rightWrapper,
}).drag();
await moveTo(getPill("Task 2"));
expect(`${SELECTORS.pillWrapper}:first`).toHaveClass(CLASSES.lockedConnectorCreator);
await cancel();
});
test("Switch to full-size browser: the connections between pills should be diplayed", async () => {
await resize({ width: 375, height: 667 });
await mountGanttView(ganttViewParams);
// Mobile view
expect("svg.o_gantt_connector").toHaveCount(0, {
message: "Gantt connectors should not be visible in small/mobile view",
});
// Resizing browser to leave mobile view
await resize({ width: 1366, height: 768 });
await runAllTimers();
expect("svg.o_gantt_connector").toHaveCount(22, {
message: "Gantt connectors should be visible when switching to desktop view",
});
});
test.tags("broken");
test("Connect two very distant pills", async () => {
ProjectTask._records = [
ProjectTask._records[0],
{
id: 2,
name: "Task 2",
planned_date_begin: "2021-11-18 08:00:00",
date_deadline: "2021-11-18 16:00:00",
user_ids: [2],
depend_on_ids: [],
},
];
onRpc("write", ({ args }) => {
expect.step(args);
});
await mountGanttView({
...ganttViewParams,
context: {
default_start_date: "2021-10-01",
default_stop_date: "2021-11-30",
},
});
expect(SELECTORS.connector).toHaveCount(0);
// Explicitly shows the connector creator wrapper since its "display: none"
// disappears on native CSS hover, which cannot be programatically emulated.
const rightWrapper = queryFirst(SELECTORS.connectorCreatorWrapper);
rightWrapper.classList.add("d-block");
// Creating a connector and hover another pill while dragging it
const { drop, moveTo } = await contains(SELECTORS.connectorCreatorBullet, {
root: rightWrapper,
}).drag();
const selector = `${SELECTORS.pill}:contains('Task 2')`;
expect(selector).toHaveCount(0);
await moveTo({ position: { x: window.innerWidth * 2 } });
await advanceFrame(200);
// FIXME: ELEMENT SHOULD BE INTERACTIVE -> test is simulating a situation that
// cannot happen. Investigate the issue, remove the following line and the
// "broken" tag when fixed.
queryOne(selector).classList.add("pe-auto");
await drop(selector);
expect.verifySteps([[[2], { depend_on_ids: [[4, 1, false]] }]]);
expect(SELECTORS.connector).toHaveCount(1);
});
test("move a pill in the same row (Maintain Buffer Reschedule)", async () => {
onRpc(({ method, model, args }) => {
if (["web_gantt_reschedule", "action_rollback_scheduling"].includes(method)) {
expect.step([method, args]);
return {
type: "success",
message: "Tasks rescheduled",
old_vals_per_pill_id: {
7: {
start: "2021-10-17 11:30:00",
stop: "2021-10-17 21:29:59",
},
},
};
}
});
await mountGanttView({
...ganttViewParams,
context: {
default_start_date: "2021-10-01",
default_stop_date: "2021-11-30",
},
});
const { moveTo, drop } = await dragPill("Task 7");
await moveTo({ columnHeader: "21", groupHeader: "October 2021", part: 2 });
expect(SELECTORS.startBadge).toHaveText("10/21/2021, 11:30 PM");
expect(SELECTORS.stopBadge).toHaveText("10/22/2021, 12:29 AM");
expect(SELECTORS.startBadge).toHaveClass("text-success");
expect(SELECTORS.stopBadge).toHaveClass("text-success");
await drop();
await animationFrame();
expect(".o_notification").toHaveCount(1);
expect(".o_notification .o_notification_buttons button").toHaveCount(1);
await contains(".o_notification .o_notification_buttons button i.fa-undo").click();
expect.verifySteps([
[
"web_gantt_reschedule",
[
{
date_deadline: "2021-10-21 23:29:59",
planned_date_begin: "2021-10-21 22:30:12",
user_ids: false,
},
"maintainBuffer",
[
7,
],
"depend_on_ids",
null,
"planned_date_begin",
"date_deadline",
],
],
[
"action_rollback_scheduling",
[
[
7,
],
{
7: {
start: "2021-10-17 11:30:00",
stop: "2021-10-17 21:29:59",
},
},
],
],
]
);
});
test("reschedule gets triggered when resizing a pill (Maintain Buffer Reschedule)", async () => {
onRpc(({method}) => {
expect.step(method);
if (["web_gantt_reschedule"].includes(method)) {
return {
type: "success",
message: "Tasks rescheduled",
};
}
});
await mountGanttView(ganttViewParams);
// resize to one cell larger (1 day)
await resizePill(getPillWrapper("Task 1"), "end", +1);
expect.verifySteps([
"get_views",
"get_gantt_data",
"web_gantt_reschedule",
"get_gantt_data",
]);
});