Files
2026-07-13 12:25:07 +08:00

648 lines
23 KiB
TypeScript

// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
import { PageView, ColumnSelector, test, expect } from "../helpers.ts";
test.describe.configure({ mode: "parallel" });
type SidebarState =
| {
open: true;
tabs: string[];
selectedTab: string;
type: string | "expression";
snapshot: string;
}
| { open: false };
type Action =
| "open"
| "activate"
| "deactivate"
| "reorder"
| "aggregate"
| "deaggregate"
| "groupby"
| "splitby"
| "orderby"
| "where";
// Given an initial state and a list of possible actions, expect the given outputs.
// Each action will be tested against the three methods - button click,
// drag'n'drop, and text input - according to what is possible.
// For instance, it's impossible to button click a column into a query position,
// so this will be skipped.
type TestSpec = {
initial_state: { open: boolean; active: boolean; columns?: string[] };
actions: Action[];
outputs: {
table_col?: ExpectedState;
expr_col?: ExpectedState;
};
};
type ExpectedState =
| { closed: true }
| { unchanged: true }
| ({ tabs: string[]; selectedTab: string } & { type?: string });
// Note: impossible states are not encoded here.
const TEST_SPEC: Record<string, TestSpec> = {
"Open - Active": {
initial_state: {
open: false,
active: true,
},
actions: ["open"],
outputs: {
table_col: {
tabs: ["Style"],
selectedTab: "Style",
},
expr_col: {
tabs: ["Style", "Attributes"],
selectedTab: "Style",
},
},
},
"Open - Inactive": {
initial_state: {
open: false,
active: false,
},
actions: ["open"],
outputs: {
// The Style tab requires the column to be in
// `view_config.columns` (see `column_settings_sidebar.rs`
// `show_styles`); inactive expression columns only see
// Attributes.
expr_col: {
tabs: ["Attributes"],
selectedTab: "Attributes",
},
},
},
Deactivate: {
initial_state: {
open: true,
active: true,
},
actions: ["deactivate"],
outputs: {
table_col: {
closed: true,
},
// Deactivating drops the column from `view_config.columns`,
// which removes the Style tab; the sidebar stays open on
// the expression with only Attributes left.
expr_col: {
tabs: ["Attributes"],
selectedTab: "Attributes",
},
},
},
Activate: {
initial_state: {
open: true,
active: false,
},
actions: ["activate"],
outputs: {
expr_col: {
tabs: ["Style", "Attributes"],
selectedTab: "Attributes",
},
},
},
"Reorder - Active": {
initial_state: {
open: true,
active: true,
},
actions: ["reorder"],
outputs: {
table_col: {
unchanged: true,
},
expr_col: {
unchanged: true,
},
},
},
Aggregate: {
initial_state: {
open: true,
active: true,
},
actions: ["aggregate"],
outputs: {
table_col: {
unchanged: true,
type: "integer",
},
expr_col: {
unchanged: true,
type: "integer",
},
},
},
Deaggregate: {
initial_state: {
open: true,
active: true,
},
actions: ["deaggregate"],
outputs: {
table_col: {
unchanged: true,
type: "string",
},
expr_col: {
unchanged: true,
type: "string",
},
},
},
"Groupby - Active": {
initial_state: {
open: true,
active: true,
},
actions: ["groupby"],
outputs: {
expr_col: {
unchanged: true,
type: "integer",
},
table_col: {
unchanged: true,
type: "integer",
},
},
},
"Splitby - Active": {
initial_state: {
open: true,
active: true,
},
actions: ["splitby"],
outputs: {
expr_col: {
unchanged: true,
},
table_col: {
unchanged: true,
},
},
},
"Filter - Active": {
initial_state: { open: true, active: true },
actions: ["orderby", "where"],
outputs: {
expr_col: { unchanged: true },
table_col: { unchanged: true },
},
},
"Query - Inactive": {
initial_state: {
open: true,
active: false,
},
actions: ["groupby", "splitby", "orderby", "where"],
outputs: {
expr_col: {
unchanged: true,
},
},
},
};
/** Restores the viewer to a simple state and returns the columnSettingsSidebar's innerHTML */
async function open(
view: PageView,
initial_state: TestSpec["initial_state"],
expr: boolean,
): Promise<[ColumnSelector, SidebarState]> {
const { active, open, columns } = initial_state;
// expr_value is a string to keep things consistent between aggregate values
const expr_name = "expr";
const expr_value = "'hello'";
let view_columns = columns ? [...columns] : ["Category", "City"];
if (active && expr) {
view_columns.push(expr_name);
}
const viewer_settings = {
settings: true,
columns: view_columns,
expressions: { [expr_name]: expr_value },
};
await view.restore(viewer_settings);
await view.page.evaluate((viewer_settings) => {
console.log(viewer_settings);
}, viewer_settings);
let selector = active
? await view.settingsPanel.activeColumns.getColumnByName(
expr ? expr_name : view_columns[0],
)
: expr
? await view.settingsPanel.inactiveColumns.getColumnByName(expr_name)
: view.settingsPanel.inactiveColumns.getColumnByType("string");
let state: SidebarState;
if (open) {
await selector.editBtn.click();
state = {
open: true,
tabs: await view.columnSettingsSidebar.getTabs(),
selectedTab: await view.columnSettingsSidebar.getSelectedTab(),
type: await view.columnSettingsSidebar.getType(),
snapshot: await view.columnSettingsSidebar.container.innerHTML(),
};
} else {
state = { open: false };
}
return [selector, state];
}
async function checkOutput(
view: PageView,
expected: ExpectedState,
initial_state: SidebarState,
) {
const POSSIBLE_TABS = ["Style", "Attributes"];
const check_tabs = async (
selectedTab: string,
tabs: string[],
unexpected_tabs: string[],
) => {
if (selectedTab === "") {
await view.columnSettingsSidebar.container
.locator(".settings_tab.selected_tab .tab-title")
.first()
.waitFor();
} else {
await view.columnSettingsSidebar.container
.locator(`.settings_tab.selected_tab #${selectedTab}`)
.waitFor();
}
for (let tab of tabs) {
if (tab === "") {
await view.columnSettingsSidebar.container
.locator(`.tab-title`)
.first()
.waitFor();
} else {
await view.columnSettingsSidebar.container
.locator(`#${tab}`)
.waitFor();
}
}
for (let tab of unexpected_tabs) {
await view.columnSettingsSidebar.tabTitle
.locator(`#${tab}`)
.waitFor({ state: "hidden" });
}
};
if ("type" in expected) {
await view.columnSettingsSidebar.container
.locator(`.type-icon.${expected.type}`)
.waitFor();
}
if ("unchanged" in expected) {
if (!initial_state.open) {
await expect(view.columnSettingsSidebar.container).toBeHidden({
timeout: 1000,
});
} else {
const unexpected_tabs = POSSIBLE_TABS.filter(
(t) => !initial_state.tabs.includes(t),
);
await check_tabs(
initial_state.selectedTab,
initial_state.tabs,
unexpected_tabs,
);
}
} else if ("closed" in expected) {
await expect(view.columnSettingsSidebar.container).toBeHidden({
timeout: 1000,
});
} else {
const unexpected_tabs = POSSIBLE_TABS.filter(
(t) => !expected.tabs.includes(t),
);
await check_tabs(expected.selectedTab, expected.tabs, unexpected_tabs);
}
}
test.beforeEach(async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/superstore-debug.html");
await page.evaluate(async () => {
while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
});
/** These tests perform interactions on the viewer column selector and check for the intended behavior on the column settings sidebar.
* They all start with the assumption that the sidebar is open.
* NOTE: if we ever allow styles from an inactive tab, then these tests will need to accomodate for it.
*/
test.describe("Column Settings State on Interaction", () => {
const excluded_actions: Record<string, Action[]> = {
button: [
"reorder",
"aggregate",
"groupby",
"splitby",
"orderby",
"where",
],
text: ["reorder", "open", "deactivate", "deaggregate"],
dnd: ["open", "deactivate", "groupby", "splitby"],
};
for (const [name, spec] of Object.entries(TEST_SPEC)) {
for (const [col_type, expected_output] of Object.entries(
spec.outputs,
)) {
const expr = col_type === "expr_col";
for (const action of spec.actions) {
const name_prefix = `${name} - ${col_type} - ${action}`;
if (!excluded_actions["button"].includes(action)) {
test(`${name_prefix} - Button`, async ({ page }) => {
let pageView = new PageView(page);
const [selector, snapshot_state] = await open(
pageView,
spec.initial_state,
expr,
);
switch (action) {
case "open": {
await selector.editBtn.click();
break;
}
case "activate": {
await selector.activeBtn.click();
break;
}
case "deactivate": {
await selector.activeBtn.click();
break;
}
case "deaggregate": {
await pageView.settingsPanel.groupby("Row ID");
await pageView.settingsPanel.removeViewParameter(
"groupby",
"Row ID",
);
break;
}
default: {
test.fail(true, "Unreachable");
}
}
await checkOutput(
pageView,
expected_output,
snapshot_state,
);
});
}
if (!excluded_actions["text"].includes(action)) {
test(`${name_prefix} - Text`, async ({ page }) => {
let pageView = new PageView(page);
const [selector, snapshot_state] = await open(
pageView,
spec.initial_state,
expr,
);
let selector_name = await selector.name.innerText();
switch (action) {
case "activate": {
await pageView.settingsPanel.activeColumns.activateColumn(
selector_name,
);
break;
}
case "groupby": {
await pageView.settingsPanel.groupby(
selector_name,
);
break;
}
case "splitby": {
await pageView.settingsPanel.splitby(
selector_name,
);
break;
}
case "orderby": {
await pageView.settingsPanel.orderby(
selector_name,
);
break;
}
case "where": {
await pageView.settingsPanel.where(
selector_name,
);
break;
}
case "aggregate": {
await pageView.settingsPanel.groupby("Row ID");
break;
}
default: {
test.fail(true, "Unreachable");
}
}
await checkOutput(
pageView,
expected_output,
snapshot_state,
);
});
}
if (!excluded_actions["dnd"].includes(action)) {
test(`${name_prefix} - Drag'n'Drop`, async ({ page }) => {
let pageView = new PageView(page);
const [selector, snapshot_state] = await open(
pageView,
spec.initial_state,
expr,
);
switch (action) {
case "activate": {
await selector.container.dragTo(
pageView.settingsPanel.activeColumns.columnSelector.last(),
);
break;
}
case "reorder": {
await selector.container.dragTo(
pageView.settingsPanel.activeColumns.columnSelector.nth(
1,
),
);
break;
}
case "groupby": {
await selector.container.dragTo(
pageView.settingsPanel.groupbyInput,
);
break;
}
case "splitby": {
await selector.container.dragTo(
pageView.settingsPanel.splitbyInput,
);
break;
}
case "orderby": {
await selector.container.dragTo(
pageView.settingsPanel.orderbyInput,
);
break;
}
case "where": {
await selector.container.dragTo(
pageView.settingsPanel.whereInput,
);
break;
}
case "aggregate": {
const aggregator =
pageView.settingsPanel.inactiveColumns.columnSelector.first();
await aggregator.dragTo(
pageView.settingsPanel.groupbyInput,
);
break;
}
case "deaggregate": {
let aggregator =
pageView.settingsPanel.inactiveColumns.columnSelector.first();
await aggregator.dragTo(
pageView.settingsPanel.groupbyInput,
);
aggregator = page
.locator("#group_by .pivot-column")
.first();
await aggregator.dragTo(
pageView.settingsPanel.activeColumns.container
.locator(".column-selector-column")
.first(),
);
}
}
await checkOutput(
pageView,
expected_output,
snapshot_state,
);
});
}
}
}
}
});
test.describe("Unique Behaviors", () => {
for (const destination of ["groupby", "splitby"]) {
test(`${destination} - Single Active Column - table_col`, async ({
page,
}) => {
const view = new PageView(page);
const [selector, state_snapshot] = await open(
view,
{ open: true, active: true, columns: ["City"] },
false,
);
await selector.container.dragTo(
destination === "groupby"
? view.settingsPanel.groupbyInput
: view.settingsPanel.splitbyInput,
);
await checkOutput(
view,
destination == "groupby"
? {
unchanged: true,
type: "integer",
}
: { unchanged: true },
state_snapshot,
);
});
test(`${destination} - Multiple Active Columns - table_col`, async ({
page,
}) => {
const view = new PageView(page);
const [selector, state_snapshot] = await open(
view,
{ open: true, active: true, columns: ["City", "State"] },
false,
);
await selector.container.dragTo(
destination === "groupby"
? view.settingsPanel.groupbyInput
: view.settingsPanel.splitbyInput,
);
// Dragging a table column onto group_by/split_by removes
// it from `view_config.columns` (so `show_styles` is false
// → no Style tab); a non-expression column has no
// Attributes tab either, so the sidebar closes.
await checkOutput(view, { closed: true }, state_snapshot);
});
}
test("switching between date and datetime should rerender", async ({
page,
}) => {
const view = new PageView(page);
await view.restore({
settings: true,
plugin: "Debug Styled",
columns: ["date", "datetime"],
expressions: { date: "date(0,0,0)", datetime: "now()" },
});
const date = await view.settingsPanel.activeColumns.getColumnByName(
"date",
true,
);
await date.editBtn.click();
await view.columnSettingsSidebar.openTab("Style");
const dateSnapshot =
await view.columnSettingsSidebar.styleTab.container.innerHTML();
const datetime = await view.settingsPanel.activeColumns.getColumnByName(
"datetime",
true,
);
await datetime.editBtn.click();
const datetimeSnapshot =
await view.columnSettingsSidebar.styleTab.container.innerHTML();
expect(datetimeSnapshot).not.toEqual(dateSnapshot);
});
});