Files
opportunities-extension/tests/opportunityTable.test.cjs

164 lines
9.8 KiB
JavaScript

const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
const { loadApplication, composeScript } = require("./support/application.cjs");
class Element {
constructor(tag) {
this.tag = tag;
this.children = [];
this.dataset = {};
this.attributes = {};
this.listeners = {};
this.classes = new Set();
this.classList = { toggle: (name, on) => on ? this.classes.add(name) : this.classes.delete(name) };
this.text = "";
}
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text; }
append(...nodes) { this.children.push(...nodes); }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, handler) { this.listeners[key] = handler; }
click() { if (!this.disabled) this.listeners.click({ preventDefault() {}, stopPropagation() {} }); }
}
const walk = node => [node, ...node.children.flatMap(walk)];
const hasClass = (node, name) => (node.className || "").split(" ").includes(name);
const findClass = (node, name) => walk(node).find(child => hasClass(child, name));
const synthetic = (number = "TEST-1", name = "Synthetic opportunity") => ({
Name: name, OptyNumber: number, CustomerAccount: { PartyUniqueName: "Synthetic customer", PartyId: "party/id", PartyNumber: "reg-123" },
Owner: { PersonFirstName: "Test", PersonLastName: "Owner" }, PrimaryRevenue: { RevnAmount: "123456.78", RevnAmountCurcyCode: "USD", WinProb: 60 },
EffectiveDate: "2026-10-23", LastUpdateDate: "2026-09-22T13:15:00Z", StatusCode: "OPEN", ForecastGroup_c: "FORECAST"
});
function setup(items = [synthetic()]) {
const table = new Element("table");
const document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag),
getElementById: id => id === app.contracts.OPPORTUNITIES_TABLE_ID ? table : null };
const app = loadApplication("content", { document, Intl, Date });
const summaries = [];
app.aiSummaryContent.requestGenerateAiSummary = number => summaries.push(number);
app.opportunitiesTable.setOpportunitiesTableState({ status: "ready", items });
const rows = () => table.children.find(child => child.tag === "tbody").children;
const cell = (key, index = 0) => rows()[index].children.find(child => child.attributes["data-column"] === key);
const headers = () => table.children.find(child => child.tag === "thead").children[0].children;
return { app, table, rows, cell, headers, summaries };
}
test("ten columns retain their links and put product/customer controls below the corresponding text", () => {
const h = setup();
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ ProdGroupName: "Cloud" }] });
h.app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(h.headers().length, 10);
assert.equal(h.headers().some(header => header.attributes["data-column"] === "actions"), false);
const name = h.cell("name").children[0];
const customer = h.cell("customer").children[0];
assert.equal(name.children[0].tag, "a");
assert.equal(name.children[0].textContent, "Synthetic opportunity");
assert.ok(name.children[0].href.endsWith("TEST-1"));
assert.equal(name.children[1].children[0].className, "opportunities-extension-products-count");
assert.equal(name.children[1].children[0].textContent, "1");
assert.equal(customer.children[0].textContent, "Synthetic customer");
assert.ok(customer.children[0].href.endsWith("party%2Fid"));
assert.equal(customer.children[1].children[0].title, "View customer consumption");
assert.equal(customer.children[1].children[1].title, "View customer installed base");
assert.equal(customer.children[1].children[2].title, "View customer service requests");
assert.equal(findClass(h.cell("optyNumber"), "opportunities-extension-copy-button").title, "Copy opportunity number");
assert.equal(findClass(h.cell("name"), "opportunities-extension-action-button"), undefined);
assert.equal(findClass(h.cell("customer"), "opportunities-extension-products-count"), undefined);
});
test("products remain expandable with AI summary and a ten-column detail row after sorting", () => {
const h = setup([synthetic("B", "Zulu"), synthetic("A", "Alpha")]);
h.app.productsContentState.opptyProductsByNumber.set("B", { items: [{ ProdGroupName: "Cloud", RevnAmount: "10" }] });
h.app.opportunitiesTable.renderOpportunitiesTable();
findClass(h.cell("name"), "opportunities-extension-products-count").click();
assert.deepEqual(h.summaries, ["B"]);
assert.equal(h.rows()[1].className, "opportunities-extension-products-detail-row");
assert.equal(h.rows()[1].children[0].colSpan, 10);
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
h.headers()[0].children[0].click();
assert.equal(h.cell("name").children[0].children[0].textContent, "Alpha");
assert.equal(h.cell("name", 1).children[0].children[0].textContent, "Zulu");
assert.equal(h.rows()[2].children[0].colSpan, 10);
findClass(h.cell("name", 1), "opportunities-extension-products-count").click();
assert.equal(h.rows().length, 2);
assert.deepEqual(h.summaries, ["B"], "collapsing does not generate another summary");
});
test("customer consumption still opens immediately, uses the right customer and preserves loading state", async () => {
const h = setup();
const opens = [];
const requests = [];
h.app.consumptionPlansView.openAccountPlansModal = (...args) => opens.push(args);
h.app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ synthetic: true });
h.app.platformContentMessages.sendRuntimeMessage = async message => { requests.push(message); return { ok: true, plans: [] }; };
findClass(h.cell("customer"), "opportunities-extension-action-button").click();
assert.equal(opens[0][2], "loading");
assert.equal(findClass(h.cell("customer"), "opportunities-extension-action-button").disabled, true);
await new Promise(resolve => setImmediate(resolve));
assert.equal(requests.length, 1);
assert.equal(requests[0].partyNumber, "reg-123");
assert.equal(requests[0].type, "opportunitiesExtension.requestAccountPlans");
assert.equal(opens.at(-1)[2], "ready");
assert.equal(findClass(h.cell("customer"), "opportunities-extension-action-button").disabled, false);
});
test("pending, zero-count, failed and missing-data rows preserve their layout and full values", () => {
const h = setup([{ ...synthetic(), StatusCode: "WON_PENDING" }]);
const actions = () => h.cell("name").children[0].children[1];
assert.equal(actions().children.length, 0, "space remains reserved while products load");
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [], error: "Synthetic failure" });
h.app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(actions().children[0].textContent, "0");
assert.equal(actions().children[0].title, "Synthetic failure");
assert.equal(h.cell("status").children[0].textContent, "WON_PENDING");
assert.equal(h.cell("revenue").textContent, "$123,456.78");
assert.ok(h.cell("lastUpdateDate").textContent.includes("2026"));
const missing = { Name: "No identifiers", StatusCode: "OPEN", ForecastGroup_c: "FORECAST" };
h.app.opportunitiesFilters.getSortedOpportunities = () => [missing];
h.app.opportunitiesTable.setOpportunitiesTableState({ status: "ready", items: [missing] });
assert.equal(findClass(h.cell("name"), "opportunities-extension-products-count"), undefined);
assert.equal(h.cell("customer").children[0].children[0].textContent, "-");
});
test("loading, empty and error messages span exactly the visible columns", () => {
const h = setup();
for (const status of ["loading", "empty", "error"]) {
h.app.opportunitiesTable.setOpportunitiesTableState({ status, message: "Synthetic state" });
assert.equal(h.rows().length, 1);
assert.equal(h.rows()[0].children[0].colSpan, 10);
}
});
test("table styles reserve compact widths, prioritize Name/Customer and keep nested tables untouched", () => {
const read = file => fs.readFileSync(path.join(__dirname, "..", file), "utf8");
const css = read("src/features/opportunities/styles.css");
const fixed = [...css.matchAll(/th\[data-column="\w+"\] \{ width: (\d+)px;/g)].reduce((sum, match) => sum + Number(match[1]), 0);
assert.equal(fixed, 916);
const flexible = [...css.matchAll(/th\[data-column="(\w+)"\] \{ width: auto;/g)].map(match => match[1]);
assert.deepEqual(flexible, ["name", "customer"]);
assert.match(css, /th\[data-column="owner"\] \{ width: 132px;/);
assert.doesNotMatch(css, /width: (?:calc|clamp)\(/);
assert.match(css, /min-width: 1120px/);
assert.match(css, /min-height: 2\.7em/);
assert.match(css, /padding-bottom: 38px/);
const actions = css.match(/\.opportunities-extension-cell-actions \{([^}]+)\}/)[1];
assert.match(actions, /position: absolute/);
assert.match(actions, /bottom: 10px/);
assert.match(actions, /height: 24px/);
assert.match(css, /min-width: 84px/);
assert.match(css, /flex: 0 0 24px/);
assert.match(css, /td\[data-column="customer"\] \{\s*position: relative;/);
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
assert.doesNotMatch(css, /text-overflow: ellipsis|overflow: hidden|th:nth-child|td:nth-child/);
for (const width of [1120, 1280, 1440, 1840]) {
const remaining = width - fixed;
assert.equal(fixed + remaining / 2 + remaining / 2, width);
assert.ok(remaining > 0);
assert.ok(remaining / 2 >= 12 + 84, "three customer controls fit after the left inset");
}
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css"].map(read).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
});