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

325 lines
20 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()], localStorage) {
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, localStorage });
app.currentUser.allowsCrmActivities = () => true;
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("Overview restores independent column and direction preferences across reloads and new data", () => {
const saved = new Map();
const storage = { getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value) };
const items = [synthetic("B", "Beta"), synthetic("A", "Alpha")];
const first = setup(items, storage);
first.app.opportunitiesTable.sortOpportunitiesBy("name");
first.app.opportunitiesTable.sortOpportunitiesBy("name");
const second = setup(items.slice().reverse(), storage);
assert.equal(second.app.opportunitiesTableState.opportunitiesTableState.sortKey, "name");
assert.equal(second.app.opportunitiesTableState.opportunitiesTableState.sortDirection, "descending");
assert.equal(second.cell("name").children[0].children[0].textContent, "Beta");
second.app.opportunitiesTable.setOpportunitiesTableState({ status: "loading" });
second.app.opportunitiesTable.setOpportunitiesTableState({ status: "ready", items });
assert.equal(second.cell("name").children[0].children[0].textContent, "Beta");
});
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(name.children[1].children[0].title, "1 product");
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(customer.children[1].children[3].title, "View customer organization chart");
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("all four filter fields share available width dynamically with responsive wrapping", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
assert.match(css, /grid-template-columns: repeat\(4, minmax\(0, 1fr\)\) max-content max-content;/);
assert.match(css, /grid-template-columns: repeat\(4, minmax\(0, 1fr\)\);/);
assert.match(css, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/);
assert.match(css, /@media \(max-width: 1150px\)/);
});
test("ramp months preserve zero and flag missing values beside the product count", () => {
const h = setup();
const badge = () => findClass(h.cell("name"), "opportunities-extension-products-ramp");
assert.equal(badge(), undefined);
for (const [values, expected] of [
[[], null], [[null], "N/A"], [[null, undefined, "", " "], "N/A"], [[6], "6"],
[[0], "0"], [[0, "3", null, " 6 ", 3], "0 | 3 | N/A | 6 | 3"]
]) {
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c }))
});
h.app.opportunitiesTable.renderOpportunitiesTable();
if (expected === null) assert.equal(badge(), undefined);
else {
assert.equal(badge().textContent, expected);
assert.equal(badge().attributes["data-missing"], String(expected.includes("N/A")));
assert.equal(badge().title, `Ramp Months: ${expected.split(" | ").join(", ")}`);
assert.equal(badge().attributes["aria-label"], badge().title);
assert.equal(h.cell("name").children[0].children[1].children[1], badge());
}
const count = findClass(h.cell("name"), "opportunities-extension-products-count");
assert.equal(count.textContent, String(values.length));
assert.equal(count.title, `${values.length} product${values.length === 1 ? "" : "s"}`);
}
});
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("product labels support multiple digits without changing their tooltip", () => {
const h = setup();
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: Array.from({ length: 12 }, () => ({})) });
h.app.opportunitiesTable.renderOpportunitiesTable();
const count = findClass(h.cell("name"), "opportunities-extension-products-count");
assert.equal(count.textContent, "12");
assert.equal(count.title, "12 products");
assert.equal(count.attributes["aria-label"], "12 products for opportunity TEST-1");
});
test("opportunity projection alert is last and aggregates only divergent products", () => {
const h = setup();
const alert = () => findClass(h.cell("name"), "opportunities-extension-opportunity-projection-alert");
const product = amount => ({ RampMonths_c: 0, workload: { adjustedACR: 1200, consumptionStartDate: "2026-10-01",
forecast: [{ year: 2026, month: 10, adjustedConsumptionAmount: amount }] } });
assert.equal(alert(), undefined);
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [product(90), product(100), product(110), {}] });
h.app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(alert().title, "Consumption forecast differs in 2 products. View products.");
const actions = findClass(h.cell("name"), "opportunities-extension-cell-actions");
assert.equal(actions.children.at(-1), alert());
assert.equal(alert().tag, "button");
assert.equal(alert().attributes["aria-expanded"], "false");
alert().click();
assert.equal(h.rows()[1].className, "opportunities-extension-products-detail-row");
assert.equal(alert().attributes["aria-expanded"], "true");
assert.equal(walk(h.rows()[1]).filter(node => node.attributes["aria-haspopup"] === "dialog").length, 2);
assert.deepEqual(h.summaries, ["TEST-1"]);
alert().click();
assert.equal(h.rows()[1].className, "opportunities-extension-products-detail-row", "clicking again never collapses the list");
assert.deepEqual(h.summaries, ["TEST-1"], "already expanded list is not reloaded");
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [product(100), {}] });
h.app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(alert(), undefined);
});
test("opportunity badges share height, typography and alignment including the Action plus button", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
const shared = css.match(/\.opportunities-extension-cell-actions > \.opportunities-extension-products-count,\s*\.opportunities-extension-cell-actions > \.opportunities-extension-products-ramp,\s*\.opportunities-extension-cell-actions > \.opportunities-extension-crm-sr-badge\s*\{([^}]+)\}/);
assert.ok(shared, "P, R, SR and Act share one scoped badge rule");
for (const declaration of ["display: inline-flex;", "align-items: center;", "justify-content: flex-start;",
"box-sizing: border-box;", "height: 20px;", "min-height: 20px;", "padding: 0 6px;", "margin: 0;",
"border-radius: 8px;", "font-family: inherit;", "font-size: 10px;", "font-weight: 600;", "line-height: 16px;"]) {
assert.ok(shared[1].includes(declaration), declaration);
}
const plus = css.match(/\.opportunities-extension-add-action\s*\{([^}]+)\}/)[1];
assert.match(plus, /height: 16px;/);
assert.match(plus, /align-items: center;/);
assert.match(plus, /font: inherit;/);
const actions = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/workload-actions.js"), "utf8");
assert.match(actions, /badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge"/);
});
test("CRM SR badge follows Ramp Months and remains visible while product data is pending", async () => {
const h = setup();
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{
SrNumber: "SR-SYNTH", OpportunityName_Id_c: { OptyNumber: "TEST-1" }, ReportedHours_c: 0
}];
await h.app.opportunityCrmServiceRequests.load("synthetic", h.app.opportunitiesQueryState.periodRequestVersion);
h.app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(findClass(h.cell("name"), "opportunities-extension-crm-sr-badge").textContent, "0h");
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ RampMonths_c: 3 }] });
h.app.opportunitiesTable.renderOpportunitiesTable();
const actions = h.cell("name").children[0].children[1];
assert.deepEqual(actions.children.map(child => child.textContent), ["1", "3", "0h", "?"]);
findClass(h.cell("name"), "opportunities-extension-products-count").click();
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
});
test("product table distinguishes unavailable ramp months from numeric zero", () => {
const h = setup();
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
items: [null, undefined, "", " ", 0, "0", 6].map(RampMonths_c => ({ RampMonths_c }))
});
h.app.opportunitiesTable.renderOpportunitiesTable();
findClass(h.cell("name"), "opportunities-extension-products-count").click();
const table = findClass(h.rows()[1], "opportunities-extension-products-table");
const rows = table.children.find(child => child.tag === "tbody").children;
assert.deepEqual(rows.map(row => row.children[9].textContent), ["N/A", "N/A", "N/A", "N/A", "0", "0", "6"]);
});
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 skeleton preserves columns and action slots, then disappears on success or failure", () => {
const h = setup();
for (const nextStatus of ["ready", "empty", "error"]) {
h.app.opportunitiesTable.setOpportunitiesTableState({ status: "loading", items: [], message: "Loading opportunities..." });
assert.equal(h.table.attributes["aria-busy"], "true");
assert.equal(h.rows().length, 8);
assert.equal(h.headers().length, 10);
assert.ok(h.headers().every(header => header.children[0].disabled));
for (const row of h.rows()) {
assert.equal(row.attributes["aria-hidden"], "true");
assert.deepEqual(row.children.map(cell => cell.attributes["data-column"]), h.headers().map(header => header.attributes["data-column"]));
assert.ok(row.children.every(cell => findClass(cell, "opportunities-extension-account-plans-skeleton-bar")));
assert.equal(walk(row).filter(node => hasClass(node, "opportunities-extension-table-skeleton-badge")).length, 8);
assert.equal(walk(row).some(node => node.tag === "button" || node.tag === "a"), false);
}
h.app.opportunitiesTable.setOpportunitiesTableState({ status: nextStatus, items: nextStatus === "ready" ? [synthetic()] : [], message: "" });
assert.equal(h.table.attributes["aria-busy"], "false");
assert.equal(findClass(h.table, "opportunities-extension-table-skeleton-row"), undefined);
assert.ok(h.headers().every(header => !header.children[0].disabled));
}
});
test("empty and error messages span exactly the visible columns", () => {
const h = setup();
for (const status of ["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: 1168px/);
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: 114px/);
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/);
const tableRules = css.replace(/[^{}]*\.opportunities-extension-products-ramp\s*\{[^}]*\}/g, "")
.replace(/[^{}]*\.opportunities-extension-title-row[^{}]*\{[^}]*\}/g, "")
.replace(/[^{}]*\.opportunities-extension-sr-confirm-dialog\s*\{[^}]*\}/g, "");
assert.doesNotMatch(tableRules, /text-overflow: ellipsis|overflow: hidden|th:nth-child|td:nth-child/);
assert.match(css, /\.opportunities-extension-products-ramp\s*\{[^}]*background: #f0e8f7/);
assert.match(css, /\[data-theme="dark"\] \.opportunities-extension-products-ramp\s*\{[^}]*color: #ead5fc/);
for (const width of [1168, 1280, 1440, 1840]) {
const remaining = width - fixed;
assert.equal(fixed + remaining / 2 + remaining / 2, width);
assert.ok(remaining > 0);
assert.ok(remaining / 2 >= 12 + 114, "four 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", "src/features/opportunities/calendar.css", "src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css"].map(read).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
});