chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:25:07 +08:00
commit a26e856398
1681 changed files with 296950 additions and 0 deletions
@@ -0,0 +1,209 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect, PageView } from "../helpers.ts";
test.beforeEach(async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/superstore-debug.html");
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
});
test.describe("Attributes Tab", () => {
test("Expression column name becomes editable when attributes tab is open", async ({
page,
}) => {
let view = new PageView(page);
await view.restore({
settings: true,
expressions: { expr: "12345" },
columns: ["expr"],
});
let expr =
await view.settingsPanel.activeColumns.getColumnByName("expr");
await expr.editBtn.click();
let input = view.columnSettingsSidebar.nameInput;
await view.columnSettingsSidebar.openTab("Style");
await expect(input).toBeDisabled();
await view.columnSettingsSidebar.openTab("Attributes");
await expect(input).toBeEnabled();
});
test("Empty expression names", async ({ page }) => {
// Setup
let expr_value = "12345";
let view = new PageView(page);
await view.openSettingsPanel();
await view.settingsPanel.createNewExpression("", expr_value);
// Empty text matches expression
let input = view.columnSettingsSidebar.nameInput;
expect(await input.evaluate((input) => input!.value)).toBe("");
expect(await input.evaluate((input) => input!.placeholder)).toBe(
expr_value,
);
// Reopening the column shows an empty header with placeholder text that matches
await view.columnSettingsSidebar.closeBtn.click();
let expr =
await view.settingsPanel.inactiveColumns.getColumnByName(
expr_value,
);
await expr.editBtn.click();
expect(await input.evaluate((input) => input!.value)).toBe("");
expect(await input.evaluate((input) => input!.placeholder)).toBe(
expr_value,
);
// Expression alias is the expression on serialization
let config = await view.save();
expect(config.expressions?.[expr_value]).toBe(expr_value);
});
test("Expression names do not overlap existing columns", async ({
page,
}) => {
// Expressions should have an error state and be unsavable when the name matches an existing column
let view = new PageView(page);
await view.restore({
expressions: { expr: "12345" },
columns: ["expr", "Row ID"],
settings: true,
});
let expr =
await view.settingsPanel.activeColumns.getColumnByName("expr");
await expr.editBtn.click();
await view.columnSettingsSidebar.openTab("Attributes");
let input = view.columnSettingsSidebar.nameInput;
await input.clear();
await input.type("Row ID", { delay: 100 });
await expect(view.columnSettingsSidebar.nameInputWrapper).toHaveClass(
/invalid/,
);
await expect(
view.columnSettingsSidebar.attributesTab.saveBtn,
).toBeDisabled();
});
test("Tab Button Click enters 4 spaces.", async ({ page }) => {
let view = new PageView(page);
await view.restore({
expressions: { expr: "12345" },
columns: ["expr", "Row ID"],
settings: true,
});
let expr =
await view.settingsPanel.activeColumns.getColumnByName("expr");
await expr.editBtn.click();
await view.columnSettingsSidebar.openTab("Attributes");
let sidebar = view.columnSettingsSidebar;
let attributesTab = sidebar.attributesTab;
let textarea = attributesTab.expressionEditor.textarea;
await textarea.type("foo", { delay: 100 });
expect(await textarea.evaluate((input) => input!.value)).toStrictEqual(
"foo12345",
);
await page.keyboard.press("Tab");
const expected = await textarea.evaluate((input) => input!.value);
expect(expected).toContain("\t");
const caretPosition = await textarea.evaluate(
(input) => input!.selectionStart,
);
expect(caretPosition).toBe(4); // length of foo + length of '\t' = 4;
});
test("Reset button", async ({ page }) => {
let view = new PageView(page);
await view.restore({
expressions: { expr: "12345" },
columns: ["expr", "Row ID"],
settings: true,
});
let expr =
await view.settingsPanel.activeColumns.getColumnByName("expr");
await expr.editBtn.click();
await view.columnSettingsSidebar.openTab("Attributes");
let sidebar = view.columnSettingsSidebar;
let attributesTab = sidebar.attributesTab;
await expect(attributesTab.resetBtn).toBeDisabled();
await sidebar.nameInput.type("foo", { delay: 100 });
await expect(attributesTab.resetBtn).toBeEnabled();
await attributesTab.resetBtn.click();
expect(
await sidebar.nameInput.evaluate((input) => input!.value),
).toStrictEqual("expr");
let textarea = attributesTab.expressionEditor.textarea;
await textarea.type("foo", {
delay: 100,
});
await expect(attributesTab.resetBtn).toBeEnabled();
await attributesTab.resetBtn.click();
expect(await textarea.evaluate((input) => input!.value)).toStrictEqual(
"12345",
);
});
test("Delete button", async ({ page }) => {
let view = new PageView(page);
await view.restore({
expressions: { expr: "12345" },
columns: ["expr", "Row ID"],
settings: true,
});
let expr =
await view.settingsPanel.activeColumns.getColumnByName("expr");
await expr.editBtn.click();
await view.columnSettingsSidebar.openTab("Attributes");
let attributesTab = view.columnSettingsSidebar.attributesTab;
await expect(attributesTab.deleteBtn).toBeDisabled();
await expr.activeBtn.click();
expr = await view.settingsPanel.inactiveColumns.getColumnByName("expr");
await expect(attributesTab.deleteBtn).toBeEnabled();
await attributesTab.deleteBtn.click();
await expect(attributesTab.container).toBeHidden();
let config = await view.save();
expect(config?.expressions).toStrictEqual({});
});
test("Rename empty header as expression value", async ({ page }) => {
let view = new PageView(page);
let settingsPanel = await view.openSettingsPanel();
await settingsPanel.createNewExpression("", "1234");
await view.columnSettingsSidebar.nameInput.type("1234");
await expect(
view.columnSettingsSidebar.nameInputWrapper,
).not.toHaveClass("invalid");
// NOTE: Currently when you rename a column as the contents of its placeholder,
// it gets serialized with the expression name. This confuses the components and it deserializes
// as if it had an empty header.
// Changing this behavior may require changing the way we serialize expressions.
// For this to work, an expression should have a unique ID. API might look like this:
// `const settings = {expressions: {"SOME_ID": {name: "foobar", expr: "123"}, "ANOTHER_ID": {expr: "'i have no name'"}}};`
// We could then change the ColumnLocator struct to look like this:
// `enum ColumnLocator {TableColumn(String), ExprColumn(Option<String>), NewExpr()}`
});
});
@@ -0,0 +1,647 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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);
});
});
@@ -0,0 +1,307 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect, PageView, ColumnSettingsSidebar } from "../helpers.ts";
test.beforeEach(async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/superstore-debug.html");
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
});
export async function checkTab(
columnSettingsSidebar: ColumnSettingsSidebar,
active: boolean,
expression: boolean,
hasStyles: boolean = true,
) {
await columnSettingsSidebar.container.waitFor({
state: "visible",
});
let titles = await columnSettingsSidebar.tabTitle.all();
if (active) {
if (expression) {
if (hasStyles) {
expect(titles.length).toBe(2);
expect(await titles[0].getAttribute("id")).toBe("Style");
expect(await titles[1].getAttribute("id")).toBe("Attributes");
} else {
expect(titles.length).toBe(1);
expect(await titles[0].getAttribute("id")).toBe("Attributes");
}
} else {
expect(titles.length).toBe(1);
expect(await titles[0].getAttribute("id")).toBe("Style");
}
} else {
if (expression) {
expect(titles.length).toBe(1);
expect(await titles[0].getAttribute("id")).toBe("Attributes");
} else {
test.fail(
true,
"No settings exist for non-expression, inactive columns!",
);
}
}
}
test.describe("Column Settings Sidebar", () => {
test("edit button > opens sidebar for active table and expression columns", async ({
page,
}) => {
let view = new PageView(page);
let settingsPanel = await view.openSettingsPanel();
let inactiveColumns = settingsPanel.inactiveColumns;
let activeColumns = settingsPanel.activeColumns;
await settingsPanel.createNewExpression("expr", "true");
await inactiveColumns.container.waitFor({
state: "visible",
});
let exprCol = await activeColumns.activateColumn("expr");
let firstCol = await activeColumns.getFirstVisibleColumn();
firstCol.editBtn.waitFor();
await firstCol.editBtn.click();
await checkTab(view.columnSettingsSidebar, true, false);
await activeColumns.scrollToBottom();
exprCol.editBtn.waitFor();
await exprCol.editBtn.click();
await checkTab(view.columnSettingsSidebar, true, true);
});
test("edit button > opens sidebar for inactive expression columns", async ({
page,
}) => {
let view = new PageView(page);
let settingsPanel = await view.openSettingsPanel();
let inactiveColumns = settingsPanel.inactiveColumns;
let activeColumns = settingsPanel.activeColumns;
await settingsPanel.createNewExpression("expr", "true");
let exprCol = await inactiveColumns.getColumnByName("expr");
await activeColumns.toggleColumn("Row ID");
let rowId = await inactiveColumns.getColumnByName("Row ID");
expect(exprCol).toBeDefined();
expect(rowId).toBeDefined();
await exprCol.editBtn.waitFor();
await rowId.editBtn.waitFor({ state: "detached", timeout: 1000 });
await view.assureColumnSettingsClosed();
await exprCol.editBtn.click();
await checkTab(view.columnSettingsSidebar, false, true);
});
test("tabs > switches between style and attributes tabs", async ({
page,
}) => {
let view = new PageView(page);
let settingsPanel = await view.openSettingsPanel();
let sidebar = view.columnSettingsSidebar;
let activeColumns = settingsPanel.activeColumns;
let inactiveColumns = settingsPanel.inactiveColumns;
await settingsPanel.createNewExpression("expr", "'string'");
await activeColumns.activateColumn("expr");
let col = await activeColumns.getColumnByName("expr");
await inactiveColumns.container.waitFor({ state: "hidden" });
await activeColumns.scrollToBottom();
await view.assureColumnSettingsOpen(col);
await checkTab(sidebar, true, true, true);
let tabs = await sidebar.tabTitle.all();
await tabs[1].click();
await sidebar.attributesTab.container.waitFor();
await tabs[0].click();
await sidebar.styleTab.container.waitFor();
});
test("state > persists sidebar when table data updates", async ({
page,
}) => {
await page.evaluate(async () => {
// @ts-ignore
let table = await window.__TEST_WORKER__.table({ x: [0] });
// @ts-ignore
window.__TEST_TABLE__ = table;
let viewer = document.querySelector("perspective-viewer");
await viewer?.load(table);
});
let view = new PageView(page);
let settingsPanel = await view.openSettingsPanel();
let col = settingsPanel.activeColumns.getFirstVisibleColumn();
await col.editBtn.click();
await view.columnSettingsSidebar.container.waitFor();
await page.evaluate(() => {
// @ts-ignore
window.__TEST_TABLE__.update({ x: [1] });
});
await page.waitForFunction(() => {
// The Debug Styled test plugin renders its row count as text into
// the viewer's light DOM; wait for it to reflect the update.
const plugin = document
.querySelector("perspective-viewer")
?.querySelector("perspective-viewer-debug-styled");
return (plugin?.textContent ?? "").includes("rows");
});
await expect(view.columnSettingsSidebar.container).toBeVisible();
});
test("layout > constrains width with long expression text", async ({
page,
}) => {
let view = new PageView(page);
const MAX_WIDTH = 350;
let checkWidth = async () => {
let width = await view.columnSettingsSidebar.container.evaluate(
(sidebar) => sidebar.getBoundingClientRect().width,
);
expect(width).toEqual(MAX_WIDTH);
};
let settings = await view.openSettingsPanel();
await settings.activeColumns.scrollToBottom();
await settings.addExpressionButton.click();
let editor = view.columnSettingsSidebar.attributesTab.expressionEditor;
await editor.textarea.focus();
await editor.textarea.clear();
await editor.textarea.type(
"'0000000000000000000000000000000000000000000000000000000000",
);
await checkWidth();
await editor.textarea.clear();
await checkWidth();
});
test("tabs > preserves selected tab when toggling and saving expression", async ({
page,
}) => {
const view = new PageView(page);
await view.restore({
expressions: {
expr: "1234",
},
columns: ["Row ID"],
settings: true,
});
const col =
await view.settingsPanel.inactiveColumns.getColumnByName("expr");
await col.editBtn.click();
await view.columnSettingsSidebar.openTab("Attributes");
await checkTab(view.columnSettingsSidebar, false, true);
const selectedTab = async () => {
return await view.columnSettingsSidebar.selectedTab
.locator(".tab-title")
.getAttribute("id");
};
expect(await selectedTab()).toBe("Attributes");
await col.activeBtn.click();
await view.columnSettingsSidebar.container
.locator(".tab-title#Style")
.waitFor({ state: "visible" });
await checkTab(view.columnSettingsSidebar, true, true, true);
expect(await selectedTab()).toBe("Attributes");
await view.columnSettingsSidebar.attributesTab.expressionEditor.textarea.clear();
await view.columnSettingsSidebar.attributesTab.expressionEditor.textarea.type(
"'new expr value'",
);
await view.columnSettingsSidebar.attributesTab.saveBtn.click();
expect(await selectedTab()).toBe("Attributes");
});
test("color range > resets to default when switching to a different number column", async ({
page,
}) => {
const view = new PageView(page);
await view.restore({
plugin: "Debug Styled",
columns: ["Row ID", "Postal Code"],
settings: true,
});
const defaultNegColor = "#ff471e";
let settingsPanel = await view.openSettingsPanel();
let activeColumns = settingsPanel.activeColumns;
let firstCol = await activeColumns.getColumnByName("Row ID");
let secondCol = await activeColumns.getColumnByName("Postal Code");
// edit first number column
await firstCol.editBtn.waitFor();
await firstCol.editBtn.click();
await checkTab(view.columnSettingsSidebar, true, false, false);
// expect style tab is selected
const selectedTab = async () => {
return await view.columnSettingsSidebar.selectedTab
.locator(".tab-title")
.getAttribute("id");
};
expect(await selectedTab()).toBe("Style");
const getFgColorNeg = async () => {
return view.columnSettingsSidebar.styleTab.container.locator(
"input.neg_fg_color",
);
};
let fgColorNeg = await getFgColorNeg();
expect(fgColorNeg).toBeTruthy();
expect(fgColorNeg).toBeVisible();
expect(fgColorNeg).toBeEditable();
expect(fgColorNeg).toHaveValue(defaultNegColor);
// change -ve color from default
await fgColorNeg.fill("#ffff00");
let negColorValue = await fgColorNeg.inputValue();
expect(negColorValue).toBe("#ffff00");
// edit second number column
await secondCol.editBtn.waitFor();
await secondCol.editBtn.click();
// expect -ve color input on color range is present
fgColorNeg = await getFgColorNeg();
expect(fgColorNeg).toBeVisible();
// expect -ve color input updates and switches back to default
negColorValue = await fgColorNeg.inputValue();
expect(negColorValue).toBe(defaultNegColor);
// edit first number column
await firstCol.editBtn.waitFor();
await firstCol.editBtn.click();
// expect -ve color input on color range is present
fgColorNeg = await getFgColorNeg();
expect(fgColorNeg).toBeVisible();
// expect -ve color input updates and switches back to the changed value
negColorValue = await fgColorNeg.inputValue();
expect(negColorValue).toBe("#ffff00");
});
});
@@ -0,0 +1,200 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import { compareContentsToSnapshot } from "@perspective-dev/test";
import { PageView } from "@perspective-dev/test";
import {
ACTIVE_DRAG,
getSettingsPanelContents,
INACTIVE_DRAG,
shadowDragCancel,
} from "./dragdrop_test_utils";
test.beforeEach(async ({ page }) => {
await page.goto(
"/rust/perspective-viewer/test/html/column-settings-enabled.html",
);
await page.evaluate(async () => {
while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
});
test.describe("Drag and Drop", () => {
// Cancel — drag starts but no drop occurs.
// These verify the panel returns to its original state with no side-effects.
test.describe("cancel", () => {
test("drag inactive column and cancel (dragend with no drop)", async ({
page,
}) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
await shadowDragCancel(
page,
view.container.locator(INACTIVE_DRAG).first(),
);
const config = await view.save();
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("drag active column and cancel (dragend with no drop)", async ({
page,
}) => {
const view = new PageView(page);
await view.restore({
settings: true,
columns: ["Sales", "Profit"],
});
await shadowDragCancel(
page,
view.container.locator(ACTIVE_DRAG).first(),
);
const config = await view.save();
expect(config.columns).toEqual(["Sales", "Profit"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("drag inactive column over wrong target (status bar) then cancel", async ({
page,
}) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
// Hover over the status bar which is not a valid drop target.
await shadowDragCancel(
page,
view.container.locator(INACTIVE_DRAG).first(),
view.container.locator("#status_bar"),
);
const config = await view.save();
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("drag inactive column over Group By then cancel before drop", async ({
page,
}) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
await shadowDragCancel(
page,
view.container.locator(INACTIVE_DRAG).first(),
view.container.locator("#group_by"),
);
const config = await view.save();
expect(config.group_by).toEqual([]);
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("drag inactive column over Split By then cancel before drop", async ({
page,
}) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
await shadowDragCancel(
page,
view.container.locator(INACTIVE_DRAG).first(),
view.container.locator("#split_by"),
);
const config = await view.save();
expect(config.split_by).toEqual([]);
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("drag inactive column over Sort then cancel before drop", async ({
page,
}) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
await shadowDragCancel(
page,
view.container.locator(INACTIVE_DRAG).first(),
view.container.locator("#sort"),
);
const config = await view.save();
expect(config.sort).toEqual([]);
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("drag inactive column over Filter then cancel before drop", async ({
page,
}) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
await shadowDragCancel(
page,
view.container.locator(INACTIVE_DRAG).first(),
view.container.locator("#filter"),
);
const config = await view.save();
expect(config.filter).toEqual([]);
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("drag inactive column outside component (simulate drop outside browser)", async ({
page,
}) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
// Start a drag then immediately cancel — no hover over any target.
await shadowDragCancel(
page,
view.container.locator(INACTIVE_DRAG).first(),
);
const config = await view.save();
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("drag active column outside component (simulate drop outside browser)", async ({
page,
}) => {
const view = new PageView(page);
await view.restore({
settings: true,
columns: ["Sales", "Profit"],
});
await shadowDragCancel(
page,
view.container.locator(ACTIVE_DRAG).first(),
);
const config = await view.save();
expect(config.columns).toEqual(["Sales", "Profit"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
});
});
@@ -0,0 +1,103 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { Locator, Page } from "@playwright/test";
/** The draggable handle of the first visible inactive column. */
export const INACTIVE_DRAG =
"#sub-columns .column-selector-column:not(.column-selector-column-hidden) .column-selector-draggable";
/** The draggable handle of the first active column. */
export const ACTIVE_DRAG =
"#active-columns .column-selector-column .column-selector-draggable";
/** Returns the `#settings_panel` innerHTML from the viewer's shadow root. */
export async function getSettingsPanelContents(page: Page) {
return page.evaluate(() => {
const viewer = document.querySelector("perspective-viewer")!;
return viewer.shadowRoot!.querySelector("#settings_panel")!.innerHTML;
});
}
/**
* Initiates a real browser drag from `src` to `tgt` using mouse events, without
* completing the drop. The drag is left in-flight so the caller can snapshot the
* intermediate dragover UI state.
*/
export async function shadowDragOver(page: Page, src: Locator, tgt: Locator) {
const srcBox = (await src.boundingBox())!;
const tgtBox = (await tgt.boundingBox())!;
const srcX = srcBox.x + srcBox.width / 2;
const srcY = srcBox.y + srcBox.height / 2;
const tgtX = tgtBox.x + tgtBox.width / 2;
const tgtY = tgtBox.y + tgtBox.height / 2;
await page.mouse.move(srcX, srcY);
await page.mouse.down();
// Small initial move to trigger the browser's drag-start threshold.
await page.mouse.move(srcX + 5, srcY, { steps: 2 });
// Move to the target center, generating dragenter + dragover events.
await page.mouse.move(tgtX, tgtY, { steps: 10 });
// Allow Yew to process events and re-render.
await page.waitForTimeout(100);
}
/**
* Initiates a real browser drag from `src`, optionally hovers over
* `wrongTgt`, then presses Escape to cancel — firing `dragend` without a
* `drop`.
*/
export async function shadowDragCancel(
page: Page,
src: Locator,
wrongTgt: Locator | null = null,
) {
const srcBox = (await src.boundingBox())!;
const srcX = srcBox.x + srcBox.width / 2;
const srcY = srcBox.y + srcBox.height / 2;
await page.evaluate(() => {
window["dragend_resolvers"] = Promise.withResolvers();
document.body.addEventListener("dragend", () => {
window["dragend_resolvers"].resolve();
});
});
await page.mouse.move(srcX, srcY);
await page.mouse.down();
await page.mouse.move(srcX + 5, srcY, { steps: 2 });
if (wrongTgt) {
const wrongBox = (await wrongTgt.boundingBox())!;
await page.mouse.move(
wrongBox.x + wrongBox.width / 2,
wrongBox.y + wrongBox.height / 2,
{ steps: 10 },
);
}
// Drag is a cruel mistress.
await page.waitForTimeout(100);
// Cancel the drag (fires dragend with no preceding drop).
await page.keyboard.press("Escape");
await page.evaluate(async () => {
await window["dragend_resolvers"].promise;
});
}
export async function localDrag(page: any, source: any, target: any) {
await source.dragTo(target, { steps: 100 });
}
@@ -0,0 +1,189 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import { compareContentsToSnapshot } from "../helpers.ts";
import { PageView } from "@perspective-dev/test";
import {
ACTIVE_DRAG,
getSettingsPanelContents,
INACTIVE_DRAG,
localDrag,
shadowDragOver,
} from "./dragdrop_test_utils";
test.beforeEach(async ({ page }) => {
await page.goto(
"/rust/perspective-viewer/test/html/column-settings-enabled.html",
);
await page.evaluate(async () => {
while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
});
test.describe("Drag and Drop", () => {
// Drag over a target without releasing (no drop)
// Tests the intermediate UI state — dragover highlights, etc.
test.describe("dragover (without drop)", () => {
test("inactive column dragged over Active Columns", async ({
page,
}) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
await shadowDragOver(
page,
view.container.locator(INACTIVE_DRAG).first(),
view.container
.locator("#active-columns .column-selector-column")
.nth(0),
);
const config = await view.save();
expect(config.columns).toEqual(["Sales"]);
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await page.pause();
await compareContentsToSnapshot(contents);
});
test("inactive column dragged over Group By", async ({ page }) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
await shadowDragOver(
page,
view.container.locator(INACTIVE_DRAG).first(),
view.container.locator("#group_by"),
);
const config = await view.save();
expect(config.group_by).toEqual([]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive column dragged over Split By", async ({ page }) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
await shadowDragOver(
page,
view.container.locator(INACTIVE_DRAG).first(),
view.container.locator("#split_by"),
);
const config = await view.save();
expect(config.split_by).toEqual([]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive column dragged over Sort", async ({ page }) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
await shadowDragOver(
page,
view.container.locator(INACTIVE_DRAG).first(),
view.container.locator("#sort"),
);
const config = await view.save();
expect(config.sort).toEqual([]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive column dragged over Filter", async ({ page }) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
await shadowDragOver(
page,
view.container.locator(INACTIVE_DRAG).first(),
view.container.locator("#filter"),
);
const config = await view.save();
expect(config.filter).toEqual([]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("active column dragged over Group By", async ({ page }) => {
const view = new PageView(page);
await view.restore({
settings: true,
columns: ["Sales", "Profit"],
});
await shadowDragOver(
page,
view.container.locator(ACTIVE_DRAG).first(),
view.container.locator("#group_by"),
);
const config = await view.save();
expect(config.group_by).toEqual([]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("active column dragged over Split By", async ({ page }) => {
const view = new PageView(page);
await view.restore({
settings: true,
columns: ["Sales", "Profit"],
});
await shadowDragOver(
page,
view.container.locator(ACTIVE_DRAG).first(),
view.container.locator("#split_by"),
);
const config = await view.save();
expect(config.split_by).toEqual([]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("active column dragged over Sort", async ({ page }) => {
const view = new PageView(page);
await view.restore({
settings: true,
columns: ["Sales", "Profit"],
});
await shadowDragOver(
page,
view.container.locator(ACTIVE_DRAG).first(),
view.container.locator("#sort"),
);
const config = await view.save();
expect(config.sort).toEqual([]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("active column dragged over Filter", async ({ page }) => {
const view = new PageView(page);
await view.restore({
settings: true,
columns: ["Sales", "Profit"],
});
await shadowDragOver(
page,
view.container.locator(ACTIVE_DRAG).first(),
view.container.locator("#filter"),
);
const config = await view.save();
expect(config.filter).toEqual([]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
});
});
@@ -0,0 +1,182 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import { compareContentsToSnapshot } from "../helpers.ts";
import { PageView } from "@perspective-dev/test";
import {
ACTIVE_DRAG,
getSettingsPanelContents,
INACTIVE_DRAG,
localDrag,
} from "./dragdrop_test_utils";
test.beforeEach(async ({ page }) => {
await page.goto(
"/rust/perspective-viewer/test/html/column-settings-enabled.html",
);
await page.evaluate(async () => {
while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
});
test.describe("Drag and Drop", () => {
test.describe("drop", () => {
test("inactive to first active columns", async ({ page }) => {
const view = new PageView(page);
await view.restore({
settings: true,
columns: ["Sales", "Profit"],
});
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container
.locator("#active-columns .column-selector-column")
.nth(0);
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.columns).toEqual(["Category", "Sales", "Profit"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive to second active columns", async ({ page }) => {
const view = new PageView(page);
await view.restore({
settings: true,
columns: ["Sales", "Profit"],
});
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container
.locator("#active-columns .column-selector-column")
.nth(1);
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.columns).toEqual(["Sales", "Category", "Profit"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("active to active (reorder)", async ({ page }) => {
const view = new PageView(page);
await view.restore({
settings: true,
columns: ["Sales", "Profit", "Quantity"],
});
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(ACTIVE_DRAG).first();
// Drop the first active column onto the third position.
const target = view.container
.locator("#active-columns .column-selector-column")
.nth(2);
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.columns).toEqual(["Profit", "Quantity", "Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive to Group By", async ({ page }) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container.locator("#group_by");
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.group_by).toEqual(["Category"]);
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive to Split By", async ({ page }) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container.locator("#split_by");
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.split_by).toEqual(["Category"]);
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive to Sort", async ({ page }) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container.locator("#sort");
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.columns).toEqual(["Sales"]);
expect(config.sort).toEqual([["Category", "asc"]]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive to Filter", async ({ page }) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container.locator("#filter");
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.filter).toEqual([["Category", "==", null]]);
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
});
});
@@ -0,0 +1,209 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import { compareContentsToSnapshot } from "@perspective-dev/test";
import { PageView } from "@perspective-dev/test";
import {
ACTIVE_DRAG,
getSettingsPanelContents,
INACTIVE_DRAG,
localDrag,
} from "./dragdrop_test_utils";
test.beforeEach(async ({ page }) => {
await page.goto(
"/rust/perspective-viewer/test/html/column-settings-enabled.html",
);
await page.evaluate(async () => {
while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
});
test.describe("Drag and Drop", () => {
test.describe("drop with Column Settings Sidebar open", () => {
test("inactive to active while sidebar is open on the active column", async ({
page,
}) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
// Open the sidebar on the current active column.
const activeCol =
view.settingsPanel.activeColumns.getFirstVisibleColumn();
await activeCol.editBtn.click();
await view.columnSettingsSidebar.container.waitFor({
state: "visible",
});
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = activeCol.container;
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.columns).toEqual(["Category", "Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("active to Group By while sidebar is open on the dragged column", async ({
page,
}) => {
const view = new PageView(page);
await view.restore({
settings: true,
columns: ["Category", "Profit"],
});
// Open the sidebar on the first active column (Sales).
const activeCol =
view.settingsPanel.activeColumns.getFirstVisibleColumn();
await activeCol.editBtn.click();
await view.columnSettingsSidebar.container.waitFor({
state: "visible",
});
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(ACTIVE_DRAG).first();
const target = view.container.locator("#group_by");
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.group_by).toEqual(["Category"]);
expect(config.columns).toEqual(["Profit"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
// TODO
test("inactive to Group By while sidebar is open on a different column", async ({
page,
}) => {
const view = new PageView(page);
await view.restore({
settings: true,
columns: ["Sales", "Profit"],
});
// Open sidebar on first active column.
const activeCol =
view.settingsPanel.activeColumns.getFirstVisibleColumn();
await activeCol.editBtn.click();
await view.columnSettingsSidebar.container.waitFor({
state: "visible",
});
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container.locator("#group_by");
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.group_by).toEqual(["Category"]);
expect(config.columns).toEqual(["Sales", "Profit"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive to Split By while sidebar is open", async ({ page }) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
const activeCol =
view.settingsPanel.activeColumns.getFirstVisibleColumn();
await activeCol.editBtn.click();
await view.columnSettingsSidebar.container.waitFor({
state: "visible",
});
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container.locator("#split_by");
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.split_by).toEqual(["Category"]);
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive to Filter while sidebar is open", async ({ page }) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
const activeCol =
view.settingsPanel.activeColumns.getFirstVisibleColumn();
await activeCol.editBtn.click();
await view.columnSettingsSidebar.container.waitFor({
state: "visible",
});
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container.locator("#filter");
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.filter).toEqual([["Category", "==", null]]);
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive to Sort while sidebar is open", async ({ page }) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
const activeCol =
view.settingsPanel.activeColumns.getFirstVisibleColumn();
await activeCol.editBtn.click();
await view.columnSettingsSidebar.container.waitFor({
state: "visible",
});
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container.locator("#sort");
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.sort).toEqual([["Category", "asc"]]);
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
});
});
@@ -0,0 +1,229 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import { compareContentsToSnapshot } from "../helpers.ts";
import { PageView } from "@perspective-dev/test";
import {
ACTIVE_DRAG,
getSettingsPanelContents,
INACTIVE_DRAG,
localDrag,
} from "./dragdrop_test_utils";
test.beforeEach(async ({ page }) => {
await page.goto(
"/rust/perspective-viewer/test/html/column-selector-modes.html",
);
await page.evaluate(async () => {
while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
});
test.describe("Drag and Drop", () => {
test.describe("drop", () => {
test("inactive to first active columns", async ({ page }) => {
const view = new PageView(page);
await view.restore({
settings: true,
columns: ["Sales", "Profit"],
});
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container
.locator("#active-columns .column-selector-column")
.nth(0);
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.columns).toEqual([
"Category",
"Profit",
null,
null,
null,
]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive to second active columns", async ({ page }) => {
const view = new PageView(page);
await view.restore({
settings: true,
columns: ["Sales", "Profit"],
});
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container
.locator("#active-columns .column-selector-column")
.nth(1);
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.columns).toEqual([
"Sales",
"Category",
null,
null,
null,
]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive to third active columns", async ({ page }) => {
const view = new PageView(page);
await view.restore({
settings: true,
columns: ["Sales", "Profit"],
});
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container
.locator("#active-columns .column-selector-column")
.nth(2);
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.columns).toEqual([
"Sales",
"Profit",
"Category",
null,
null,
]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("active to active (reorder)", async ({ page }) => {
const view = new PageView(page);
await view.restore({
settings: true,
columns: ["Sales", "Profit", "Quantity"],
});
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(ACTIVE_DRAG).first();
const target = view.container
.locator("#active-columns .column-selector-column")
.nth(2);
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.columns).toEqual([
"Quantity",
"Profit",
"Sales",
null,
null,
]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive to Group By", async ({ page }) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container.locator("#group_by");
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.group_by).toEqual(["Category"]);
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive to Split By", async ({ page }) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container.locator("#split_by");
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.split_by).toEqual(["Category"]);
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive to Sort", async ({ page }) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container.locator("#sort");
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.columns).toEqual(["Sales"]);
expect(config.sort).toEqual([["Category", "asc"]]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
test("inactive to Filter", async ({ page }) => {
const view = new PageView(page);
await view.restore({ settings: true, columns: ["Sales"] });
const configUpdated = await view.getEventListener(
"perspective-config-update",
);
const source = view.container.locator(INACTIVE_DRAG).first();
const target = view.container.locator("#filter");
await localDrag(page, source, target);
await configUpdated();
const config = await view.save();
expect(config.filter).toEqual([["Category", "==", null]]);
expect(config.columns).toEqual(["Sales"]);
const contents = await getSettingsPanelContents(page);
await compareContentsToSnapshot(contents);
});
});
});
+129
View File
@@ -0,0 +1,129 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 * as fs from "node:fs";
import * as path from "node:path";
import * as url from "node:url";
import type { ViewerConfigUpdate } from "@perspective-dev/viewer";
import type { Locator, Page } from "@playwright/test";
import * as prettier from "prettier";
// Re-export test framework (preserves consoleLogs auto-fixture)
export { test, expect } from "@perspective-dev/test";
// Re-export page object models
export { PageView } from "@perspective-dev/test";
export { ColumnSettingsSidebar } from "@perspective-dev/test/src/js/models/column_settings.ts";
export { ColumnSelector } from "@perspective-dev/test/src/js/models/settings_panel.ts";
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const API_VERSION: string = JSON.parse(
fs
.readFileSync(
path.resolve(__dirname, "../../../../tools/test/package.json"),
)
.toString(),
)["version"];
export const DEFAULT_CONFIG: ViewerConfigUpdate = {
aggregates: {},
columns_config: {},
columns: [],
expressions: {},
filter: [],
group_by: [],
group_rollup_mode: "rollup",
plugin: "",
plugin_config: {},
settings: false,
sort: [],
split_by: [],
version: API_VERSION,
table: "load-viewer-csv",
title: null,
theme: "Pro Light",
};
export async function compareContentsToSnapshot(
contents: string,
extraSnapshotPath?: string[],
): Promise<void> {
const { expect, test } = await import("@playwright/test");
let titlePath = test.info().titlePath;
if (extraSnapshotPath) {
titlePath = titlePath.concat(extraSnapshotPath);
}
const snapshotPath = [
titlePath
.slice(1)
.map((s) =>
s
.trim()
.replace(/[^a-z0-9]+/gi, "-")
.toLowerCase(),
)
.join("-") + ".txt",
];
const pathArray = Array.isArray(snapshotPath)
? snapshotPath
: [snapshotPath];
const cleanedContents = contents
.replace(/style=""/g, "")
.replace(/(min-|max-)?(width|height): *\d+\.*\d+(px)?;? */g, "");
const formatted = await prettier.format(cleanedContents, {
parser: "html",
});
await expect(formatted).toMatchSnapshot(pathArray);
}
export async function compareNodes(
left: Locator,
right: Locator,
page: Page,
): Promise<boolean> {
const leftEl = await left.elementHandle();
const rightEl = await right.elementHandle();
return await page.evaluate(
async (compare) => {
return compare.leftEl?.isEqualNode(compare.rightEl) || false;
},
{ leftEl, rightEl },
);
}
export async function getShadowContents(page: Page): Promise<string> {
const raw = await page.evaluate(async () => {
const viewer =
document.querySelector("perspective-viewer")!.shadowRoot!;
return viewer.innerHTML;
});
return await prettier.format(raw, {
parser: "html",
});
}
export async function getLightContents(page: Page): Promise<string> {
return await page.evaluate(async () => {
const viewer = document.querySelector(
"perspective-viewer perspective-viewer-plugin",
)!;
return viewer.innerHTML;
});
}
@@ -0,0 +1,200 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 type { ViewerConfigUpdate } from "@perspective-dev/viewer";
import { test, compareContentsToSnapshot } from "../helpers.ts";
export type ContentExtractor = (page: any) => Promise<string>;
async function restoreViewer(page: any, viewerConfig: ViewerConfigUpdate) {
return await page.evaluate(async (viewerConfig: ViewerConfigUpdate) => {
const viewer = document.querySelector("perspective-viewer")!;
await viewer.restore(viewerConfig);
}, viewerConfig);
}
function runSimpleCompareTest(
viewerConfig: ViewerConfigUpdate,
extractContent: ContentExtractor,
snapshotPath: string[],
) {
return async ({ page }: { page: any }) => {
await restoreViewer(page, viewerConfig);
const content = await extractContent(page);
await compareContentsToSnapshot(content);
};
}
export function run_standard_tests(
context: string,
extractContent: ContentExtractor,
) {
test("grid > renders without settings panel", async ({ page }) => {
await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer")!;
await viewer.getTable();
await viewer.restore({ settings: true });
});
const content = await extractContent(page);
await compareContentsToSnapshot(content);
});
test("columns > displays only visible columns", async ({ page }) => {
await restoreViewer(page, {
columns: ["Discount", "Profit", "Sales", "Quantity"],
});
const visibleColumnContent = await extractContent(page);
await compareContentsToSnapshot(visibleColumnContent);
});
test.describe("Pivot tests", () => {
test(
"group_by > pivots by a single row",
runSimpleCompareTest(
{ group_by: ["State"], settings: true },
extractContent,
[context, `pivot-by-row.txt`],
),
);
test(
"group_by > pivots by two rows",
runSimpleCompareTest(
{
group_by: ["Category", "Sub-Category"],
settings: true,
},
extractContent,
[context, `pivot-by-two-rows.txt`],
),
);
test(
"split_by > pivots by a single column",
runSimpleCompareTest(
{ split_by: ["Category"], settings: true },
extractContent,
[context, `pivot-by-column.txt`],
),
);
test(
"pivot > pivots by a row and a column",
runSimpleCompareTest(
{
group_by: ["State"],
split_by: ["Category"],
settings: true,
},
extractContent,
[context, `pivot-by-row-and-column.txt`],
),
);
test(
"pivot > pivots by two rows and two columns",
runSimpleCompareTest(
{
group_by: ["Region", "State"],
split_by: ["Category", "Sub-Category"],
settings: true,
},
extractContent,
[context, `pivot-by-two-rows-and-two-columns.txt`],
),
);
});
test.describe("Sort tests", () => {
test(
"sort > sorts by a hidden column",
runSimpleCompareTest(
{
columns: ["Row ID", "Quantity"],
sort: [["Sales", "asc"]],
settings: true,
},
extractContent,
[context, `sort-by-hidden-column.txt`],
),
);
test(
"sort > sorts by a numeric column",
runSimpleCompareTest(
{
columns: ["Row ID", "Sales"],
sort: [["Quantity", "asc"]],
settings: true,
},
extractContent,
[context, `sort-by-numeric-column.txt`],
),
);
test(
"sort > sorts by an alpha column",
runSimpleCompareTest(
{
columns: ["Row ID", "State", "Sales"],
sort: [["State", "asc"]],
settings: true,
},
extractContent,
[context, `sort-by-alpha-column.txt`],
),
);
});
test.describe("Filter tests", () => {
test(
"filter > filters by a numeric column",
runSimpleCompareTest(
{
columns: ["Row ID", "State", "Sales"],
filter: [["Sales", ">", 500]],
settings: true,
},
extractContent,
[context, `filter-by-numeric-column.txt`],
),
);
test(
"filter > filters by an alpha column",
runSimpleCompareTest(
{
columns: ["Row ID", "State", "Sales"],
filter: [["State", "==", "Texas"]],
settings: true,
},
extractContent,
[context, `filter-by-alpha-column.txt`],
),
);
test(
"filter > filters with 'in' comparator",
runSimpleCompareTest(
{
columns: ["Row ID", "State", "Sales"],
filter: [["State", "in", ["Texas", "California"]]],
settings: true,
},
extractContent,
[context, `filter-with-in-comparator.txt`],
),
);
});
}
@@ -0,0 +1,87 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect, DEFAULT_CONFIG, API_VERSION } from "../helpers.ts";
test.beforeEach(async ({ page }) => {
await page.goto(
"/node_modules/@perspective-dev/viewer/test/html/plugin-priority-order.html",
);
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
});
test.describe("Close button", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/superstore.html");
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.restore({ plugin: "Debug" });
await viewer.getTable();
await viewer.restore({ settings: true });
});
});
test("close button hides the settings panel", async ({ page }) => {
const shadowRoot = page.locator("perspective-viewer");
await shadowRoot.locator("#settings_close_button").first().click();
const saved = await page.evaluate(async () =>
document.querySelector("perspective-viewer").save(),
);
expect(saved.settings).toBe(false);
});
test("settings panel round-trips open/close via save()", async ({
page,
}) => {
// Open → close via API → open again. save() must reflect each state.
const saved1 = await page.evaluate(async () =>
document.querySelector("perspective-viewer").save(),
);
expect(saved1.settings).toBe(true);
await page.evaluate(async () =>
document.querySelector("perspective-viewer").restore({
settings: false,
}),
);
const saved2 = await page.evaluate(async () =>
document.querySelector("perspective-viewer").save(),
);
expect(saved2.settings).toBe(false);
await page.evaluate(async () =>
document.querySelector("perspective-viewer").restore({
settings: true,
}),
);
const saved3 = await page.evaluate(async () =>
document.querySelector("perspective-viewer").save(),
);
expect(saved3.settings).toBe(true);
});
});
@@ -0,0 +1,66 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect, DEFAULT_CONFIG, API_VERSION } from "../helpers.ts";
test.beforeEach(async ({ page }) => {
await page.goto(
"/node_modules/@perspective-dev/viewer/test/html/plugin-priority-order.html",
);
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
});
test.describe("Plugin Registration", () => {
test("priority > loads highest priority plugin by default", async ({
page,
}) => {
let saved = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
window.__TABLE__ = await viewer.getTable();
await viewer.reset();
return await viewer.save();
});
const expected = {
...DEFAULT_CONFIG,
version: API_VERSION,
columns: [
"Row ID",
"Order ID",
"Order Date",
"Ship Date",
"Ship Mode",
"Customer ID",
"Segment",
"Country",
"City",
"State",
"Postal Code",
"Region",
"Product ID",
"Category",
"Sub-Category",
"Sales",
"Quantity",
"Discount",
"Profit",
],
plugin: "HighPriority",
};
expect(saved).toEqual(expected);
});
});
@@ -0,0 +1,59 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 {
test,
expect,
compareContentsToSnapshot,
getShadowContents,
} from "../helpers.ts";
const get_contents = getShadowContents;
test.describe("Settings Panel", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/superstore.html");
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
await document.querySelector("perspective-viewer").restore({
plugin: "Debug",
});
});
});
test("toggle > opens when settings is true", async ({ page }) => {
await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.getTable();
await viewer.restore({ settings: true });
});
const contents = await get_contents(page);
await compareContentsToSnapshot(contents);
});
test("toggle > stays closed when settings is false", async ({ page }) => {
await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.getTable();
await viewer.restore({ settings: false });
});
const contents = await get_contents(page);
await compareContentsToSnapshot(contents);
});
});
@@ -0,0 +1,56 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "../helpers.ts";
import * as path from "node:path";
test.beforeEach(async ({ page }) => {
const p = path.resolve(
"rust/perspective-viewer/test/html/superstore-single-threaded.html",
);
await page.goto(`file://${p}`);
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
await document.querySelector("perspective-viewer")!.restore({
plugin: "Debug",
});
});
});
test.describe("Single-Threaded Engine", () => {
test("render > produces correct output in single-threaded mode", async ({
page,
}) => {
await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer")!;
await viewer.restore({
group_by: ["State"],
columns: ["Sales", "Profit"],
settings: true,
});
});
const value = await page.evaluate(async () => {
return document.querySelector("perspective-viewer")!.innerText
.length;
});
// Superstore as a CSV
expect(value).toEqual(836);
});
});
@@ -0,0 +1,163 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect, compareContentsToSnapshot } from "../helpers.ts";
test.beforeEach(async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/superstore.html");
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
await document.querySelector("perspective-viewer").restore({
plugin: "Debug",
});
});
});
test.describe("Memory Leaks", () => {
// This originally has a timeout of 120000
test("elements > no leak on repeated element recreation", async ({
page,
}) => {
let viewer = await page.$("perspective-viewer");
await page.evaluate(async (viewer) => {
window.__TABLE__ = await viewer.getTable();
await viewer.reset();
}, viewer);
// From a helpful blog
// https://media-codings.com/articles/automatically-detect-memory-leaks-with-puppeteer
await page.evaluate(() => window.gc());
const heap1 = await page.evaluate(
() => performance.memory.usedJSHeapSize,
);
for (var i = 0; i < 500; i++) {
await page.evaluate(async () => {
const element = document.querySelector("perspective-viewer");
await element.delete();
document.body.innerHTML =
"<perspective-viewer></perspective-viewer>";
const new_element =
document.querySelector("perspective-viewer");
await new_element.load(Promise.resolve(window.__TABLE__));
});
}
// TODO this is very generous memory allowance suggests we
// leak ~0.1% per instance.
// TODO: Not yet sure how to access window.gc() in Playwright
await page.evaluate(() => window.gc());
const heap2 = await page.evaluate(
() => performance.memory.usedJSHeapSize,
);
expect((heap2 - heap1) / heap1).toBeLessThan(1);
const contents = await page.evaluate(async () => {
const element = document.querySelector("perspective-viewer");
await element.toggleConfig();
return element.innerHTML;
});
await compareContentsToSnapshot(contents);
});
test("views > no leak on repeated group_by changes", async ({ page }) => {
let viewer = await page.$("perspective-viewer");
await page.evaluate(async (viewer) => {
window.__TABLE__ = await viewer.getTable();
await viewer.reset();
}, viewer);
await page.evaluate(() => window.gc());
const heap1 = await page.evaluate(
() => performance.memory.usedJSHeapSize,
);
for (var i = 0; i < 500; i++) {
await page.evaluate(async (element) => {
await element.reset();
let pivots = [
"State",
"City",
"Segment",
"Ship Mode",
"Region",
"Category",
];
let start = Math.floor(Math.random() * pivots.length);
let length = Math.ceil(Math.random() * (pivots.length - start));
await element.restore({
group_by: pivots.slice(start, length),
});
}, viewer);
}
await page.evaluate(() => window.gc());
const heap2 = await page.evaluate(
() => performance.memory.usedJSHeapSize,
);
expect((heap2 - heap1) / heap1).toBeLessThan(0.5);
const contents = await page.evaluate(async (viewer) => {
await viewer.restore({ group_by: ["State"] });
await viewer.toggleConfig();
return viewer.innerHTML;
}, viewer);
await compareContentsToSnapshot(contents);
});
test("views > no leak on repeated filter changes", async ({ page }) => {
let viewer = await page.$("perspective-viewer");
await page.evaluate(async (viewer) => {
window.__TABLE__ = await viewer.getTable();
await viewer.reset();
}, viewer);
await page.evaluate(() => window.gc());
const heap1 = await page.evaluate(
() => performance.memory.usedJSHeapSize,
);
for (var i = 0; i < 500; i++) {
await page.evaluate(async (element) => {
await element.reset();
await element.restore({
filter: [["Sales", ">", Math.random() * 100 + 100]],
});
}, viewer);
}
await page.evaluate(() => window.gc());
const heap2 = await page.evaluate(
() => performance.memory.usedJSHeapSize,
);
expect((heap2 - heap1) / heap1).toBeLessThan(0.5);
const contents = await page.evaluate(async (viewer) => {
await viewer.restore({
filter: [["Sales", "<", 10]],
});
await viewer.toggleConfig();
return viewer.innerHTML;
}, viewer);
await compareContentsToSnapshot(contents);
});
});
@@ -0,0 +1,47 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test } from "../helpers.ts";
import { run_standard_tests } from "../helpers/standard_tests.ts";
async function get_contents(page) {
return await page.evaluate(async () => {
const viewer = document.querySelector(
"perspective-viewer perspective-viewer-plugin",
);
// Don't format - light DOM is CSV in a <pre> tag.
return viewer.innerHTML;
});
}
test.describe("Superstore Inline", () => {
test.beforeEach(async function init({ page }) {
await page.goto(
"/node_modules/@perspective-dev/viewer/test/html/superstore-inline.html",
);
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
await document.querySelector("perspective-viewer").restore({
plugin: "Debug",
});
});
});
run_standard_tests("superstore inline", get_contents);
});
@@ -0,0 +1,47 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test } from "../helpers.ts";
import { run_standard_tests } from "../helpers/standard_tests.ts";
async function get_contents(page) {
return await page.evaluate(async () => {
const viewer = document.querySelector(
"perspective-viewer perspective-viewer-plugin",
);
// Don't format - light DOM is CSV in a <pre> tag.
return viewer.innerHTML;
});
}
test.describe("Superstore", () => {
test.beforeEach(async function init({ page }) {
await page.goto(
"/node_modules/@perspective-dev/viewer/test/html/superstore.html",
);
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
await document.querySelector("perspective-viewer").restore({
plugin: "Debug",
});
});
});
run_standard_tests("superstore", get_contents);
});
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": ".",
"allowImportingTsExtensions": true,
"types": ["node"]
},
"include": ["./**/*.ts"]
}
@@ -0,0 +1,66 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test } from "../helpers.ts";
test.beforeEach(async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/superstore.html");
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
await document.querySelector("perspective-viewer").restore({
plugin: "Debug",
});
});
});
test.describe("Delete", async () => {
test("delete > blocks until the underlying view is removed", async ({
page,
}) => {
await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.restore({
settings: true,
group_by: ["State"],
columns: ["Profit", "Sales"],
});
const table = await viewer.getTable();
await viewer.delete();
await table.delete();
});
});
// `delete()` is destructive now
test.skip("delete > flush after delete waits for view removal", async ({
page,
}) => {
await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.restore({
settings: true,
group_by: ["State"],
columns: ["Profit", "Sales"],
});
const table = await viewer.getTable();
viewer.delete();
await viewer.flush();
await table.delete();
});
});
});
@@ -0,0 +1,337 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect, compareContentsToSnapshot } from "../helpers.ts";
const RESULT = {
aggregates: {},
columns: [
"Row ID",
"Order ID",
"Order Date",
"Ship Date",
"Ship Mode",
"Customer ID",
"Segment",
"Country",
"City",
"State",
"Postal Code",
"Region",
"Product ID",
"Category",
"Sub-Category",
"Sales",
"Quantity",
"Discount",
"Profit",
],
columns_config: {},
expressions: {},
filter: [],
group_by: [],
plugin: "Debug",
plugin_config: {},
settings: false,
sort: [],
split_by: [],
table: "load-viewer-csv",
theme: "Pro Light",
title: null,
group_rollup_mode: "rollup",
};
test.beforeEach(async ({ page }) => {
await page.goto(
"/node_modules/@perspective-dev/viewer/test/html/superstore.html",
);
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
await document.querySelector("perspective-viewer")!.restore({
plugin: "Debug",
});
});
});
test.describe("DOM API", () => {
test.describe("restore with table name > sets default plugin and columns", () => {
test("await order > resolves correctly when fully awaited", async ({
page,
}) => {
const x = await page.evaluate(async () => {
const old = document.querySelector("perspective-viewer")!;
old.parentElement!.removeChild(old);
const viewer = document.createElement("perspective-viewer");
await viewer.load((window as any).__TEST_WORKER__);
await viewer.restore({ table: "load-viewer-csv" });
return await viewer.save();
});
delete x.version;
expect(x).toEqual(RESULT);
});
test("await order > resolves correctly when load is not awaited", async ({
page,
}) => {
const x = await page.evaluate(async () => {
const old = document.querySelector("perspective-viewer")!;
old.parentElement!.removeChild(old);
const viewer = document.createElement("perspective-viewer");
viewer.load((window as any).__TEST_WORKER__);
await viewer.restore({ table: "load-viewer-csv" });
return await viewer.save();
});
delete x.version;
expect(x).toEqual(RESULT);
});
test("await order > resolves correctly when restore is not awaited", async ({
page,
}) => {
const x = await page.evaluate(async () => {
const old = document.querySelector("perspective-viewer")!;
old.parentElement!.removeChild(old);
const viewer = document.createElement("perspective-viewer");
await viewer.load((window as any).__TEST_WORKER__);
viewer.restore({ table: "load-viewer-csv" });
return await viewer.save();
});
delete x.version;
expect(x).toEqual(RESULT);
});
test("await order > resolves correctly when neither load nor restore is awaited", async ({
page,
}) => {
const x = await page.evaluate(async () => {
const old = document.querySelector("perspective-viewer")!;
old.parentElement!.removeChild(old);
const viewer = document.createElement("perspective-viewer");
viewer.load((window as any).__TEST_WORKER__);
viewer.restore({ table: "load-viewer-csv" });
return await viewer.save();
});
delete x.version;
expect(x).toEqual(RESULT);
});
});
test.describe("load and restore before DOM append", () => {
test("append > renders correctly when fully awaited", async ({
page,
}) => {
const contents = await page.evaluate(async () => {
const old = document.querySelector("perspective-viewer")!;
old.parentElement!.removeChild(old);
const viewer = document.createElement("perspective-viewer");
await viewer.load((window as any).__TEST_WORKER__);
await viewer.restore({ table: "load-viewer-csv" });
document.body.appendChild(viewer);
await viewer.flush();
return document.body.innerHTML;
});
await compareContentsToSnapshot(contents);
});
test("append > renders correctly when restore is not awaited", async ({
page,
}) => {
const contents = await page.evaluate(async () => {
const old = document.querySelector("perspective-viewer")!;
old.parentElement!.removeChild(old);
const viewer = document.createElement("perspective-viewer");
await viewer.load((window as any).__TEST_WORKER__);
viewer.restore({ table: "load-viewer-csv" });
document.body.appendChild(viewer);
await viewer.flush();
return document.body.innerHTML;
});
await compareContentsToSnapshot(contents);
});
});
});
// Regression: `<perspective-workspace>.addViewer({ columns_config })` was
// losing the `columns_config` because the workspace awaits `viewer.restore`
// while the viewer is still detached from the DOM (the Lumino widget hasn't
// attached yet, so `onAfterAttach` hasn't fired). On that code path:
// (a) `create_view`'s `reset_clean` early-return could leave `view_schema`
// unpopulated in some interleavings, and
// (b) `query_column_config_schema` then returned an EMPTY schema, which the
// strip-on-write in `update_columns_configs` treats as "no allowed
// keys" — dropping every entry in the incoming `columns_config`.
// These tests reproduce the workspace's call sequence (createElement →
// load(client) → restore(config) → appendChild) with non-trivial
// `columns_config` / `plugin_config` payloads. They use the test-only
// "Debug Styled" plugin (declared in the viewer test module), which — like a
// real plugin — declares a non-empty `column_config_schema` /
// `plugin_config_schema`. An empty-schema plugin would make the regression
// untestable: the strip would drop the keys whether or not the bug is present.
test.describe("restore with config payload before DOM append", () => {
test.beforeEach(async ({ page }) => {
await page.goto(
"/rust/perspective-viewer/test/html/superstore-debug.html",
);
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
});
test("save > columns_config round-trips when fully awaited", async ({
page,
}) => {
const config = await page.evaluate(async () => {
const old = document.querySelector("perspective-viewer")!;
old.parentElement!.removeChild(old);
const viewer = document.createElement("perspective-viewer");
await viewer.load((window as any).__TEST_WORKER__);
await viewer.restore({
table: "load-viewer-csv",
plugin: "Debug Styled",
columns: ["Profit"],
columns_config: {
Profit: {
number_format: {
style: "currency",
currency: "USD",
},
},
},
});
document.body.appendChild(viewer);
await viewer.flush();
return await viewer.save();
});
expect(config.columns_config).toMatchObject({
Profit: {
number_format: {
style: "currency",
currency: "USD",
},
},
});
});
test("save > columns_config round-trips when restore is not awaited", async ({
page,
}) => {
const config = await page.evaluate(async () => {
const old = document.querySelector("perspective-viewer")!;
old.parentElement!.removeChild(old);
const viewer = document.createElement("perspective-viewer");
await viewer.load((window as any).__TEST_WORKER__);
viewer.restore({
table: "load-viewer-csv",
plugin: "Debug Styled",
columns: ["Profit"],
columns_config: {
Profit: {
number_format: {
style: "currency",
currency: "USD",
},
},
},
});
document.body.appendChild(viewer);
await viewer.flush();
return await viewer.save();
});
expect(config.columns_config).toMatchObject({
Profit: {
number_format: {
style: "currency",
currency: "USD",
},
},
});
});
test("save > plugin_config round-trips when fully awaited", async ({
page,
}) => {
const config = await page.evaluate(async () => {
const old = document.querySelector("perspective-viewer")!;
old.parentElement!.removeChild(old);
const viewer = document.createElement("perspective-viewer");
await viewer.load((window as any).__TEST_WORKER__);
await viewer.restore({
table: "load-viewer-csv",
plugin: "Debug Styled",
columns: ["Profit"],
plugin_config: { edit_mode: "EDIT" },
});
document.body.appendChild(viewer);
await viewer.flush();
return await viewer.save();
});
expect(config.plugin_config).toMatchObject({ edit_mode: "EDIT" });
});
test("columns_config is applied to the plugin on first render", async ({
page,
}) => {
const applied = await page.evaluate(async () => {
const old = document.querySelector("perspective-viewer")!;
old.parentElement!.removeChild(old);
const viewer = document.createElement("perspective-viewer");
await viewer.load((window as any).__TEST_WORKER__);
await viewer.restore({
table: "load-viewer-csv",
plugin: "Debug Styled",
columns: ["Profit"],
columns_config: {
Profit: {
number_format: {
style: "currency",
currency: "USD",
},
},
},
});
document.body.appendChild(viewer);
await viewer.flush();
const plugin = viewer.querySelector(
"perspective-viewer-debug-styled",
) as any;
return plugin?._restored_columns_config;
});
expect(applied).toMatchObject({
Profit: {
number_format: {
style: "currency",
currency: "USD",
},
},
});
});
});
@@ -0,0 +1,119 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 {
test,
expect,
compareContentsToSnapshot,
API_VERSION,
getShadowContents,
} from "../helpers.ts";
const get_contents = getShadowContents;
test.beforeEach(async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/superstore.html");
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
await document.querySelector("perspective-viewer")!.restore({
plugin: "Debug",
});
});
});
test.describe("Events", () => {
test("config-update event > fires on restore", async ({ page }) => {
const config = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
if (!viewer) {
throw new Error("Viewer not found");
}
// @ts-ignore
await viewer.getTable();
let config;
viewer.addEventListener("perspective-config-update", (event) => {
// @ts-ignore
config = event.detail;
});
// @ts-ignore
await viewer.restore({
settings: true,
group_by: ["State"],
columns: ["Profit", "Sales"],
});
return config;
});
expect(config).toEqual({
version: API_VERSION,
aggregates: {},
split_by: [],
columns: ["Profit", "Sales"],
columns_config: {},
expressions: {},
filter: [],
plugin: "Debug",
plugin_config: {},
group_by: ["State"],
group_rollup_mode: "rollup",
settings: true,
sort: [],
table: "load-viewer-csv",
theme: "Pro Light",
title: null,
});
const contents = await get_contents(page);
await compareContentsToSnapshot(contents);
});
test("config-update event > fires on title edit", async ({ page }) => {
await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
window["acc"] = [];
await viewer!.restore({
settings: true,
});
viewer!.addEventListener("perspective-config-update", (event) => {
window["acc"].push(event.detail);
});
});
const titleInput = page.locator("perspective-viewer #status_bar input");
await titleInput.focus();
await titleInput.pressSequentially("New Title");
await titleInput.blur();
const result = await page.evaluate(async () => {
return window["acc"];
});
expect(result.map((x) => x.title)).toEqual(["New Title"]);
const config = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
return await viewer?.save();
});
expect(config.title).toEqual("New Title");
});
});
@@ -0,0 +1,123 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "../helpers.ts";
async function load(page) {
await page.goto("/rust/perspective-viewer/test/html/blank.html");
await page.waitForFunction(() => "WORKER" in window);
const viewer = page.locator("perspective-viewer");
await viewer.evaluate(async (viewer) => {
const table = (await window.WORKER).table("a,b,c\n1,2,3\n4,5,6");
await viewer.load(table);
await viewer.flush();
});
return viewer;
}
test.describe("Viewer Export", () => {
test("export > defaults to CSV", async ({ page }) => {
const viewer = await load(page);
const result = await viewer.evaluate((viewer) => viewer.export());
expect(typeof result).toBe("string");
expect(result).toBe('"a","b","c"\n1,2,3\n4,5,6\n');
});
test("export > csv method", async ({ page }) => {
const viewer = await load(page);
const result = await viewer.evaluate((viewer) => viewer.export("csv"));
expect(result).toBe('"a","b","c"\n1,2,3\n4,5,6\n');
});
test("export > json method", async ({ page }) => {
const viewer = await load(page);
const result = await viewer.evaluate(async (viewer) =>
JSON.stringify(await viewer.export("json")),
);
const parsed = JSON.parse(result as string);
expect(parsed).toEqual({ a: [1, 4], b: [2, 5], c: [3, 6] });
});
test("export > ndjson method", async ({ page }) => {
const viewer = await load(page);
const result = await viewer.evaluate((viewer) =>
viewer.export("ndjson"),
);
const lines = (result as string)
.trim()
.split("\n")
.map((l) => JSON.parse(l));
expect(lines).toEqual([
{ a: 1, b: 2, c: 3 },
{ a: 4, b: 5, c: 6 },
]);
});
test("export > arrow method returns ArrayBuffer", async ({ page }) => {
const viewer = await load(page);
const byteLength = await viewer.evaluate(async (viewer) => {
const result = await viewer.export("arrow");
return (result as ArrayBuffer).byteLength;
});
expect(byteLength).toBeGreaterThan(0);
});
});
test.describe("Viewer Export UTF8", () => {
test("export > json to csv round-trip", async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/blank.html");
await page.waitForFunction(() => "WORKER" in window);
const viewer = page.locator("perspective-viewer");
await viewer.evaluate(async (viewer) => {
const table = (await window.WORKER).table([
{
: "2023/01/01",
: "第四分局",
: "南屯區",
split: "五權西路與環中路口",
A1: 0.0,
A2: 3.0,
A3: 130.0,
總件數: 18.0,
死亡人數: 0.0,
受傷人數: null,
: "未注意車前狀態",
},
{
: "2023/01/01",
: "第六分局",
: "西屯區",
split: "中清聯絡道與環中路口",
A1: 0.0,
A2: 2.0,
A3: 100.0,
總件數: 15.0,
死亡人數: 0.0,
受傷人數: null,
: "未注意車前狀態",
},
]);
await viewer.load(table);
await viewer.flush();
});
const result = await viewer.evaluate((viewer) => viewer.export("csv"));
expect(result).toBe(
`"年月","轄區分局","路口名稱","路口名稱split","A1","A2","A3","總件數","死亡人數","主要肇因","受傷人數"\n2023-01-01,"第四分局","南屯區","五權西路與環中路口",0,3,130,18,0,"未注意車前狀態",\n2023-01-01,"第六分局","西屯區","中清聯絡道與環中路口",0,2,100,15,0,"未注意車前狀態",\n`,
);
});
});
@@ -0,0 +1,137 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "../helpers.ts";
test.beforeEach(async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/superstore.html");
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
await document.querySelector("perspective-viewer").restore({
plugin: "Debug",
});
});
});
test.describe("Flush method", async () => {
test("flush > awaits settings field update", async ({ page }) => {
const old_config = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
return await viewer.save();
});
expect(old_config.settings).toBeFalsy();
const config = await page.evaluate(async (columns) => {
const viewer = document.querySelector("perspective-viewer");
await viewer.getTable();
viewer.restore({
settings: true,
});
await viewer.flush();
return await viewer.save();
});
expect(config).toEqual({
...old_config,
settings: true,
});
});
test("flush > awaits view query field updates", async ({ page }) => {
const old_config = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
return await viewer.save();
});
expect(old_config.settings).toBeFalsy();
const config = await page.evaluate(async (columns) => {
const viewer = document.querySelector("perspective-viewer");
await viewer.getTable();
viewer.restore({
settings: true,
group_by: ["State", "City"],
split_by: ["Category"],
});
await viewer.flush();
return await viewer.save();
});
expect(config).toEqual({
...old_config,
group_by: ["State", "City"],
split_by: ["Category"],
settings: true,
});
});
test("flush > awaits config-update event triggered by restore", async ({
page,
}) => {
const result = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.getTable();
let count = 0;
viewer.addEventListener("perspective-config-update", () => {
count++;
});
viewer.restore({
settings: true,
group_by: ["State", "City"],
split_by: ["Category"],
});
const first = count;
await viewer.flush();
const second = count;
return [first, second];
});
expect(result).toEqual([0, 1]);
});
test("flush > awaits config-update event on load and does not repeat on DOM connect", async ({
page,
}) => {
const result = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
const table = await viewer.getTable();
await viewer.delete();
viewer.parentElement.removeChild(viewer);
const new_viewer = document.createElement("perspective-viewer");
let count = 0;
new_viewer.addEventListener("perspective-config-update", () => {
count++;
});
new_viewer.load(table);
const first = count;
await new_viewer.flush();
const second = count;
document.body.appendChild(new_viewer);
await new_viewer.flush();
const third = count;
return [first, second, third];
});
expect(result).toEqual([0, 1, 1]);
});
});
@@ -0,0 +1,47 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "../helpers.ts";
test.describe("Focus", async () => {
test.beforeEach(async function init({ page }) {
await page.goto(
"/node_modules/@perspective-dev/viewer/test/html/superstore_with_input.html",
);
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
await document.querySelector("perspective-viewer").restore({
plugin: "Debug",
});
});
});
test("focus > preserves external widget focus during restore", async ({
page,
}) => {
const viewer = page.locator("perspective-viewer");
const tagName = await viewer.evaluate(async (viewer) => {
const input = document.querySelector("input");
input.focus();
await viewer.restore({ group_by: ["State"] });
return document.activeElement.tagName;
});
expect(tagName).toEqual("INPUT");
});
});
@@ -0,0 +1,142 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 {
test,
expect,
compareContentsToSnapshot,
getShadowContents,
} from "../helpers.ts";
const get_contents = getShadowContents;
test.describe("Viewer Load", () => {
test("load > resolves with valid Table promise", async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/blank.html");
await page.waitForFunction(() => "WORKER" in window);
const viewer = page.locator("perspective-viewer");
await viewer.evaluate(async (viewer) => {
const goodTable = (await window.WORKER).table("a,b,c\n1,2,3");
return viewer.load(goodTable);
});
await expect(viewer).toHaveText(/"a","b","c"/); // column titles
});
test("load > rejects with failed Table promise", async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/blank.html");
await page.waitForFunction(() => "WORKER" in window);
const viewer = page.locator("perspective-viewer");
await expect(
viewer.evaluate((viewer) => {
const errorTable = Promise.reject(new Error("blimpy"));
return viewer.load(errorTable);
}),
).rejects.toThrow("blimpy");
});
test("load > recovers after a rejected Table promise", async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/blank.html");
await page.waitForFunction(() => "WORKER" in window);
const viewer = page.locator("perspective-viewer");
const didError = await viewer.evaluate(async (viewer) => {
const errorTable = Promise.reject(new Error("blimpy"));
const worker = await window.WORKER;
let didError = false;
try {
await viewer.load(errorTable);
} catch (e) {
if (e.message.includes("blimpy")) {
didError = true;
}
}
const goodTable = worker.table("a,b,c\n1,2,3");
await viewer.load(goodTable);
return didError;
});
expect(didError).toBe(true);
});
test("load > is inert when called twice with the same Table", async ({
page,
}) => {
await page.goto("/rust/perspective-viewer/test/html/superstore.html");
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
await document.querySelector("perspective-viewer").restore({
plugin: "Debug",
});
});
const contents = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.restore({ settings: true });
const table = await viewer.getTable();
await viewer.load(table);
await viewer.flush();
return viewer.shadowRoot.innerHTML;
});
await compareContentsToSnapshot(
contents,
"load-called-twice-with-the-same-table.txt",
);
});
test("load > does not throw when restore is called during a failed load", async ({
page,
consoleLogs,
}) => {
const errors = [];
page.on("pageerror", async (msg) => {
errors.push(`${msg.name}::${msg.message}`);
});
await page.goto("/rust/perspective-viewer/test/html/blank.html", {
waitUntil: "networkidle",
});
await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
viewer.load(
new Promise((_, reject) => reject("Intentional Load Error")),
);
try {
await viewer.restore({
settings: true,
plugin: "Debug",
});
} catch (e) {
// We need to catch this error else the `evaluate()` fails.
// We need to await the call because we want it to fail
// before continuing the test.
console.error("Caught error:", e);
}
await new Promise((x) => setTimeout(x, 1000));
});
const contents = await get_contents(page);
expect(errors).toEqual([
'Error::Failed to construct table from JsValue("Intentional Load Error")',
]);
consoleLogs.expectedLogs.push("error", /Intentional Load Error/);
});
});
@@ -0,0 +1,89 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "../helpers.ts";
// Uses test-only plugins ("Debug Styled" / "Debug Alt") declared in the
// viewer test module so this spec does not depend on the descendant
// `@perspective-dev/viewer-datagrid` / `-charts` packages.
test.beforeEach(async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/superstore-debug.html");
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
});
test.describe("Reset", () => {
test("soft reset restores columns_config after plugin swap", async ({
page,
}) => {
const result = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer")!;
await viewer.getTable();
await viewer.restore({
plugin: "Debug Styled",
columns: ["Profit"],
columns_config: {
Profit: {
number_format: {
style: "currency",
currency: "USD",
},
},
},
});
await viewer.restore({ plugin: "Debug Alt" });
await viewer.reset();
await viewer.flush();
// The active plugin is slotted into the viewer's light DOM, not
// its shadow root.
const plugin = viewer.querySelector(
"perspective-viewer-debug-styled",
) as any;
const config = await viewer.save();
return {
active_plugin: config.plugin,
saved_columns_config: config.columns_config,
applied_columns_config: plugin?._restored_columns_config,
};
});
// Soft reset reverts to the default plugin ...
expect(result.active_plugin).toBe("Debug Styled");
// ... the preserved `columns_config` is re-applied to that plugin
// (the plugin's own `restore()` saw it) ...
expect(result.applied_columns_config).toMatchObject({
Profit: {
number_format: {
style: "currency",
currency: "USD",
},
},
});
// ... and it survives the round-trip back out through `save()`.
expect(result.saved_columns_config).toMatchObject({
Profit: {
number_format: {
style: "currency",
currency: "USD",
},
},
});
});
});
@@ -0,0 +1,198 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 {
test,
expect,
DEFAULT_CONFIG,
compareContentsToSnapshot,
API_VERSION,
getShadowContents,
} from "../helpers.ts";
const get_contents = getShadowContents;
test.beforeEach(async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/superstore.html");
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
await document.querySelector("perspective-viewer").restore({
plugin: "Debug",
});
});
});
test.describe("Save/Restore", async () => {
test("save > returns the current viewer config", async ({ page }) => {
const config = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.getTable();
await viewer.restore({
settings: true,
group_by: ["State"],
columns: ["Profit", "Sales"],
});
return await viewer.save();
});
expect(config).toEqual({
...DEFAULT_CONFIG,
columns: ["Profit", "Sales"],
plugin: "Debug",
group_by: ["State"],
settings: true,
});
const contents = await get_contents(page);
await compareContentsToSnapshot(contents);
});
test("restore > roundtrips a saved config", async ({ page }) => {
const config = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.getTable();
await viewer.restore({
settings: true,
group_by: ["State"],
columns: ["Profit", "Sales"],
});
return await viewer.save();
});
expect(config).toEqual({
...DEFAULT_CONFIG,
columns: ["Profit", "Sales"],
plugin: "Debug",
group_by: ["State"],
settings: true,
});
const config2 = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.reset();
return await viewer.save();
});
expect(config2).toEqual({
...DEFAULT_CONFIG,
columns: [
"Row ID",
"Order ID",
"Order Date",
"Ship Date",
"Ship Mode",
"Customer ID",
"Segment",
"Country",
"City",
"State",
"Postal Code",
"Region",
"Product ID",
"Category",
"Sub-Category",
"Sales",
"Quantity",
"Discount",
"Profit",
],
plugin: "Debug",
settings: true,
theme: "Pro Light",
});
const config3 = await page.evaluate(async (config) => {
const viewer = document.querySelector("perspective-viewer");
await viewer.restore(config);
return await viewer.save();
}, config);
expect(config3).toEqual({
...DEFAULT_CONFIG,
columns: ["Profit", "Sales"],
plugin: "Debug",
group_by: ["State"],
settings: true,
theme: "Pro Light",
});
const contents = await get_contents(page);
await compareContentsToSnapshot(contents);
});
test("save > roundtrips in string format", async ({ page }) => {
const config = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.getTable();
await viewer.restore({
settings: true,
group_by: ["State"],
columns: ["Profit", "Sales"],
});
return await viewer.save("string");
});
const config3 = await page.evaluate(async (config) => {
const viewer = document.querySelector("perspective-viewer");
await viewer.reset();
await viewer.restore(config);
return await viewer.save();
}, config);
expect(config3).toEqual({
...DEFAULT_CONFIG,
columns: ["Profit", "Sales"],
plugin: "Debug",
group_by: ["State"],
settings: true,
theme: "Pro Light",
});
const contents = await get_contents(page);
await compareContentsToSnapshot(contents);
});
test("save > roundtrips in arraybuffer format", async ({ page }) => {
const config3 = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.getTable();
await viewer.restore({
settings: true,
group_by: ["State"],
columns: ["Profit", "Sales"],
});
const config = await viewer.save("arraybuffer");
await viewer.reset();
await viewer.restore(config);
return await viewer.save();
});
expect(config3).toEqual({
...DEFAULT_CONFIG,
columns: ["Profit", "Sales"],
plugin: "Debug",
group_by: ["State"],
settings: true,
theme: "Pro Light",
});
const contents = await get_contents(page);
await compareContentsToSnapshot(contents);
});
});
@@ -0,0 +1,90 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
test.beforeEach(async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/superstore.html");
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer")!;
await viewer.restore({ plugin: "Debug" });
// Explicitly register both themes so the memoized theme cache contains
// "Pro Dark" even though superstore.html only loads pro.css.
await viewer.resetThemes(["Pro Light", "Pro Dark"]);
});
});
test.describe("Theme", () => {
test("restore sets the theme attribute on the host element", async ({
page,
}) => {
await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer")!;
await viewer.getTable();
await viewer.restore({ theme: "Pro Dark" });
});
const themeAttr = await page.evaluate(() =>
document.querySelector("perspective-viewer")!.getAttribute("theme"),
);
expect(themeAttr).toBe("Pro Dark");
});
test("save returns the correct theme after restore", async ({ page }) => {
const saved = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer")!;
await viewer.getTable();
await viewer.restore({ theme: "Pro Dark" });
return await viewer.save();
});
expect(saved.theme).toBe("Pro Dark");
});
test("theme is preserved across settings panel open/close toggle", async ({
page,
}) => {
// Set theme, then toggle settings open and closed.
// This exercises the UpdateSettingsOpen path which must NOT wipe
// available_themes from the PresentationProps snapshot.
const saved = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer")!;
await viewer.getTable();
await viewer.restore({ theme: "Pro Dark" });
await viewer.restore({ settings: true });
await viewer.restore({ settings: false });
return await viewer.save();
});
expect(saved.theme).toBe("Pro Dark");
expect(saved.settings).toBe(false);
});
test("switching theme updates the theme attribute", async ({ page }) => {
const themeAttr = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer")!;
await viewer.getTable();
await viewer.restore({ theme: "Pro Dark" });
await viewer.restore({ theme: "Pro Light" });
return viewer.getAttribute("theme");
});
expect(themeAttr).toBe("Pro Light");
});
});
@@ -0,0 +1,95 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "../helpers.ts";
test.describe("View Lifecycle", () => {
test("conflation > silences view-not-found during rapid restore", async ({
page,
}) => {
await page.goto(
"/rust/perspective-viewer/test/html/superstore_lazy_viewer.html",
);
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
let vnf = false;
page.on("console", (msg) => {
if (msg.type() === "error") {
if (msg.text().includes("View not found")) {
vnf = true;
}
}
});
await page.evaluate(async () => {
const worker = window.__TEST_WORKER__;
let resolve;
let is_paused = false;
const BasePlugin = customElements.get("perspective-viewer-plugin");
class PausePlugin extends BasePlugin {
get_static_config() {
return {
name: "pause-plugin",
select_mode: "select",
config_column_names: [],
};
}
async draw(view) {
if (is_paused) {
await new Promise((x) => {
resolve = x;
});
}
const size = await view.num_rows();
this.textContent = `Rows: ${size}`;
}
}
customElements.define("pause-plugin", PausePlugin);
const Viewer = customElements.get("perspective-viewer");
Viewer.registerPlugin("pause-plugin");
// use a new viewer because only new viewers get loaded with the registered plugin
const viewer = document.createElement("perspective-viewer");
document.body.append(viewer);
worker.table("a,b,c\n1,2,3", { name: "A" });
await viewer.load(worker);
await viewer.restore({ table: "A", plugin: "pause-plugin" });
is_paused = true;
// Change in 4.1.0 - empty restore now does not render
const restore_task = viewer.restore({
plugin: "pause-plugin",
});
while (!resolve) {
await new Promise((x) => setTimeout(x, 0));
}
await new Promise((x) => setTimeout(x, 0));
resolve();
resolve = undefined;
is_paused = false;
await restore_task;
});
expect(vnf).toBeFalsy();
});
});
@@ -0,0 +1,51 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 {
test,
compareContentsToSnapshot,
getShadowContents,
} from "../helpers.ts";
const get_contents = getShadowContents;
test.beforeEach(async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/plugin-resize.html");
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
});
test.describe("Cancellable Views", () => {
test("resize > does not throw after view deletion", async ({ page }) => {
await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.restore({
group_by: ["State"],
columns: ["Sales"],
settings: true,
filter: [
["State", "not in", ["California", "Texas", "New York"]],
],
});
const view = await viewer.getView();
await view.delete();
await viewer.resize({ force: true });
});
const contents = await get_contents(page);
await compareContentsToSnapshot(contents);
});
});
@@ -0,0 +1,45 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "../helpers.ts";
test.beforeEach(async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/superstore.html");
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
await document.querySelector("perspective-viewer").restore({
plugin: "Debug",
});
});
});
test.describe("Custom Elements", () => {
test("registration > registers copy and export menu elements", async ({
page,
}) => {
const export_exists = await page.evaluate(async () => {
return !!window.customElements.get("perspective-export-menu");
});
const copy_exists = await page.evaluate(async () => {
return !!window.customElements.get("perspective-copy-menu");
});
expect(export_exists).toBeTruthy();
expect(copy_exists).toBeTruthy();
});
});
@@ -0,0 +1,417 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 {
test,
expect,
PageView,
compareContentsToSnapshot,
} from "../helpers.ts";
async function openSidebarAndScrollToBottom() {
const elem = document.querySelector("perspective-viewer");
await elem.getTable();
await elem.toggleConfig(true);
elem.shadowRoot.querySelector("#active-columns").scrollTop = 500;
}
async function checkSaveDisabled(page, expr) {
let view = new PageView(page);
let settingsPanel = await view.openSettingsPanel();
await settingsPanel.createNewExpression("", expr, false);
}
test.beforeEach(async ({ page }) => {
await page.goto(
"/node_modules/@perspective-dev/viewer/test/html/superstore.html",
);
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
await document.querySelector("perspective-viewer").restore({
plugin: "Debug",
});
});
});
test.describe("Expressions", () => {
test("editor > opens on add-column button click", async ({ page }) => {
await page.evaluate(openSidebarAndScrollToBottom);
await page.waitForFunction(
() =>
!!document
.querySelector("perspective-viewer")
.shadowRoot.querySelector("#add-expression"),
);
await page.locator("perspective-viewer #add-expression").click();
await page.waitForFunction(() => {
const root = document
.querySelector("perspective-viewer")
.shadowRoot.querySelector("#editor-container");
return !!root;
});
const editor = await page.waitForFunction(async () => {
const root = document
.querySelector("perspective-viewer")
.shadowRoot.querySelector("#editor-container");
return root.querySelector("#content");
});
const contents = await editor.evaluate((x) => x.outerHTML);
await page.evaluate(() => document.activeElement.blur());
await compareContentsToSnapshot(contents);
});
test("editor > closes on close button click", async ({ page }) => {
await page.evaluate(openSidebarAndScrollToBottom);
await page.waitForFunction(
() =>
!!document
.querySelector("perspective-viewer")
.shadowRoot.querySelector("#add-expression"),
);
await page.locator("perspective-viewer #add-expression").click();
await page.waitForSelector("#editor-container");
await page.evaluate(async () => {
let root = document.querySelector("perspective-viewer").shadowRoot;
await root.querySelector("#column_settings_close_button").click();
});
await page.waitForSelector("#editor-container", {
state: "hidden",
});
const contents = await page.evaluate(async () => {
let root = document.querySelector("perspective-viewer").shadowRoot;
return (
root.querySelector("#editor-container")?.innerHTML || "MISSING"
);
});
await compareContentsToSnapshot(contents);
});
test("validation > disables save for unknown symbols", async ({ page }) => {
await checkSaveDisabled(page, "abc");
});
test("validation > disables save for type-invalid expression", async ({
page,
}) => {
await checkSaveDisabled(page, '"Sales" + "Category";');
});
test("validation > disables save for invalid column references", async ({
page,
}) => {
await checkSaveDisabled(page, '"aaaa" + "Sales";');
});
test("columns > shows both aliased and non-aliased expressions", async ({
page,
}) => {
const contents = await page.evaluate(async () => {
document.activeElement.blur();
const elem = document.querySelector("perspective-viewer");
await elem.toggleConfig(true);
await elem.restore({
expressions: { "1 + 2": "1 + 2", abc: "3 + 4" },
});
return elem.shadowRoot.querySelector("#sub-columns").innerHTML;
});
await compareContentsToSnapshot(contents);
});
// No longer relevant as we cannot save a duplicate identifier
test.skip("columns > overwrites a duplicate expression alias", async ({
page,
}) => {
let view = new PageView(page);
view.restore({ expressions: { "4 + 5": "3 + 4" } });
await view.settingsPanel.createNewExpression("", "4 + 5", true);
const contents = await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
return elem.shadowRoot.querySelector("#sub-columns").innerHTML;
});
await compareContentsToSnapshot(contents);
});
// No longer relevant as we cannot save a duplicate identifier
test.skip("columns > overwrites a duplicate expression", async ({
page,
}) => {
let view = new PageView(page);
view.restore({ expressions: { "3 + 4": "3 + 4" } });
await view.settingsPanel.createNewExpression("", "3 + 4", true);
const contents = await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
return elem.shadowRoot.querySelector("#sub-columns").innerHTML;
});
await compareContentsToSnapshot(contents);
});
test("reset > deletes all expressions on full reset", async ({ page }) => {
await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
await elem.toggleConfig(true);
await elem.restore({
expressions: { "3 + 4": "3 + 4", "1 + 2": "1 + 2" },
});
await elem.reset(true);
});
const contents = await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
return (
elem.shadowRoot.querySelector("#sub-columns")?.innerHTML ||
"MISSING"
);
});
await compareContentsToSnapshot(contents);
});
test("reset > preserves expressions on partial reset", async ({ page }) => {
await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
await elem.toggleConfig(true);
await elem.restore({
expressions: { "3 + 4": "3 + 4", "1 + 2": "1 + 2" },
});
await elem.reset(false);
});
const content = await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
return elem.shadowRoot.querySelector("#sub-columns").innerHTML;
});
await compareContentsToSnapshot(content);
});
test("reset > deletes expressions used in columns on full reset", async ({
page,
}) => {
await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
await elem.toggleConfig(true);
await elem.restore({
columns: ["1 + 2"],
expressions: { "3 + 4": "3 + 4", "1 + 2": "1 + 2" },
});
await elem.reset(true);
});
const contents = await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
return (
elem.shadowRoot.querySelector("#sub-columns")?.innerHTML ||
"MISSING"
);
});
await compareContentsToSnapshot(contents);
});
test("reset > preserves expressions used in columns on partial reset", async ({
page,
}) => {
await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
await elem.toggleConfig(true);
await elem.restore({
columns: ["1 + 2"],
expressions: { "3 + 4": "3 + 4", "1 + 2": "1 + 2" },
});
await elem.reset(false);
});
const contents = await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
return elem.shadowRoot.querySelector("#sub-columns").innerHTML;
});
await compareContentsToSnapshot(contents);
});
test("reset > deletes expressions used in group_by/sort/filter on full reset", async ({
page,
}) => {
await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
await elem.toggleConfig(true);
await elem.restore({
columns: ["1 + 2"],
group_by: ["3 + 4"],
sort: [["1 + 2", "asc"]],
filter: [["1 + 2", "==", 3]],
expressions: { "3 + 4": "3 + 4", "1 + 2": "1 + 2" },
});
await elem.reset(true);
});
const contents = await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
return (
elem.shadowRoot.querySelector("#sub-columns")?.innerHTML ||
"MISSING"
);
});
await compareContentsToSnapshot(contents);
});
test("persistence > survives views that don't reference them", async ({
page,
}) => {
await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
await elem.toggleConfig(true);
await elem.restore({
expressions: { "3 + 4": "3 + 4", "1 + 2": "1 + 2" },
});
await elem.restore({
columns: ["State"],
});
});
const contents = await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
return elem.shadowRoot.querySelector("#sub-columns").innerHTML;
});
await compareContentsToSnapshot(contents);
});
test("add button > marks on hover", async ({ page }) => {
await page.evaluate(openSidebarAndScrollToBottom);
let addExprButton = await page.waitForSelector("#add-expression");
let notHovered = await addExprButton.getAttribute("class");
expect(notHovered).toBeNull();
await addExprButton.hover();
let hovered = await addExprButton.getAttribute("class");
expect(hovered).toBe("dragdrop-hover");
});
// Currently does not work in Firefox!
test("add button > marks on click", async ({ page }) => {
await page.evaluate(openSidebarAndScrollToBottom);
let addExprButton = await page.waitForSelector("#add-expression");
let unclicked = await addExprButton.getAttribute("class");
expect(unclicked).toBeNull();
await addExprButton.click();
await page.evaluate(openSidebarAndScrollToBottom);
let clicked = await addExprButton.getAttribute("class");
expect(clicked).toBe("dragdrop-hover");
});
test("persistence > survives views that reference them", async ({
page,
}) => {
await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
await elem.toggleConfig(true);
await elem.restore({
expressions: { "3 + 4": "3 + 4", "1 + 2": "1 + 2" },
});
await elem.restore({
columns: ["3 + 4"],
});
});
const contents = await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
return elem.shadowRoot.querySelector("#sub-columns").innerHTML;
});
await compareContentsToSnapshot(contents);
});
test("aggregates > applies aggregate to expression column", async ({
page,
}) => {
await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
await elem.toggleConfig(true);
await elem.restore({
expressions: { '"Sales" + 100': '"Sales" + 100' },
aggregates: { '"Sales" + 100': "avg" },
group_by: ["State"],
columns: ['"Sales" + 100'],
});
});
const contents = await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
return elem.shadowRoot.querySelector("#sub-columns").innerHTML;
});
await compareContentsToSnapshot(contents);
});
test("sort > sorts by a hidden expression column", async ({ page }) => {
await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
await elem.toggleConfig(true);
await elem.restore({
expressions: { '"Sales" + 100': '"Sales" + 100' },
sort: [['"Sales" + 100', "asc"]],
columns: ["Row ID"],
});
});
const contents = await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
return elem.shadowRoot.querySelector("#sub-columns").innerHTML;
});
await compareContentsToSnapshot(contents);
});
test("filter > filters by an expression column", async ({ page }) => {
await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
await elem.toggleConfig(true);
await elem.restore({
expressions: { '"Sales" + 100': '"Sales" + 100' },
filter: [['"Sales" + 100', ">", 150]],
columns: ["Row ID", '"Sales" + 100'],
});
});
const contents = await page.evaluate(async () => {
const elem = document.querySelector("perspective-viewer");
return elem.shadowRoot.querySelector("#sub-columns").innerHTML;
});
await compareContentsToSnapshot(contents);
});
});
@@ -0,0 +1,134 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 {
test,
expect,
DEFAULT_CONFIG,
compareContentsToSnapshot,
getShadowContents,
} from "../helpers.ts";
const get_contents = getShadowContents;
test.beforeEach(async ({ page }) => {
await page.goto("/rust/perspective-viewer/test/html/superstore.html");
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
await page.evaluate(async () => {
await document.querySelector("perspective-viewer").restore({
plugin: "Debug",
});
});
});
test.describe("Filter Config", () => {
test("not_in > renders filtered results correctly", async ({ page }) => {
await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.restore({
group_by: ["State"],
columns: ["Sales"],
settings: true,
filter: [
["State", "not in", ["California", "Texas", "New York"]],
],
});
});
const contents = await get_contents(page);
await compareContentsToSnapshot(contents);
});
test("in > generates correct config from dropdown selection", async ({
page,
}) => {
await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.restore({
group_by: ["State"],
columns: ["Sales"],
settings: true,
filter: [["State", "in", []]],
});
const filter = viewer.shadowRoot.querySelector(
".pivot-column input[type=search]",
);
filter.value = "C";
const event = new Event("input", {
bubbles: true,
cancelable: true,
});
filter.dispatchEvent(event);
});
const elem = await page.waitForSelector("perspective-dropdown");
await page.evaluate((elem) => {
let node = elem.shadowRoot.querySelector("span:first-of-type");
var clickEvent = document.createEvent("MouseEvents");
clickEvent.initEvent("mousedown", true, true);
node.dispatchEvent(clickEvent);
}, elem);
const config = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.flush();
return await viewer.save();
});
expect(config).toEqual({
...DEFAULT_CONFIG,
columns: ["Sales"],
filter: [["State", "in", ["California"]]],
group_by: ["State"],
plugin: "Debug",
settings: true,
});
const contents = await get_contents(page);
await compareContentsToSnapshot(contents);
});
test("numeric input > appends digits without re-rendering on trailing zeroes", async ({
page,
}) => {
await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.restore({
filter: [["Sales", ">", 1.1]],
settings: true,
});
});
const numFilter = page.locator("perspective-viewer input.num-filter");
await numFilter.focus();
await numFilter.pressSequentially("0001");
await numFilter.blur();
const value = await page.evaluate(async () => {
const viewer = document.querySelector("perspective-viewer");
await viewer.flush();
return viewer.shadowRoot.querySelector("input.num-filter").value;
});
expect(value).toEqual("1.10001");
const contents = await get_contents(page);
await compareContentsToSnapshot(contents);
});
});
@@ -0,0 +1,69 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 as PspViewer, expect, test } from "../helpers.ts";
import fs from "node:fs";
import { fileURLToPath } from "url";
import path from "path";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
test.describe("Localization", function () {
test.beforeEach(async ({ page }) => {
await page.goto("/tools/test/src/html/basic-test.html");
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
});
test("labels > renders no visible label text", async function ({ page }) {
const view = new PspViewer(page);
await view.openSettingsPanel();
const editBtn = view.dataGrid.regularTable.editBtnRow
.locator("th.psp-menu-enabled span")
.first();
await editBtn.click();
await view.columnSettingsSidebar.container.waitFor();
const contents = await page.evaluate(() => {
const viewer = document.querySelector("perspective-viewer");
return Array.from(viewer.shadowRoot.querySelectorAll("label"))
.map((x) => x.textContent)
.filter((x) => x != "");
});
expect(contents).toEqual(["+", "-"]);
});
const intl = fs
.readFileSync(`${__dirname}/../../../src/themes/intl.css`)
.toString();
const keys = Array.from(intl.matchAll(/--[a-zA-Z0-9\-]+/g)).flat();
const langfiles = fs.readdirSync(`${__dirname}/../../../src/themes/intl`);
for (const file of langfiles) {
test(`${file} > contains all required intl keys`, async function ({
page,
}) {
const langfile = fs
.readFileSync(`${__dirname}/../../../src/themes/intl/${file}`)
.toString();
for (const key of keys) {
const re = new RegExp(key, "g");
const x = langfile.match(re);
expect(x).toEqual([key]);
}
});
}
});