Implemented the customer rate card view, including SKU search

This commit is contained in:
2026-09-22 20:25:41 -03:00
parent 9ac5ffb11d
commit b4d8d9dbc1
23 changed files with 1854 additions and 130 deletions

View File

@@ -29,6 +29,7 @@ test("request categories cover every current family and unknown requests stay vi
requestAccountSummary: "consumption", requestAccountPlans: "consumption",
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests",
requestConsumptionPlanDetail: "consumption",
requestRatecardByPlan: "consumption",
requestConsumptionServiceQuarterly: "consumption", requestConsumptionServiceMonthly: "consumption",
requestConsumptionServiceWeekly: "consumption", requestConsumptionServiceDaily: "consumption", futureRequest: "other"
})) assert.equal(h.app.debugContentView.getRequestCategory({ name }), category, name);
@@ -95,7 +96,7 @@ test("tabs support arrows, Home and End with a single selected tab and labelled
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
const root = path.join(__dirname, "..");
const css = ["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"]
const css = ["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"]
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));

View File

@@ -158,6 +158,6 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
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"].map(read).join("\n");
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"].map(read).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
});

332
tests/ratecard.test.cjs Normal file
View File

@@ -0,0 +1,332 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
const { loadBackground, response, plain } = require("./support/characterization.cjs");
const { loadApplication } = require("./support/application.cjs");
const type = "opportunitiesExtension.requestRatecardByPlan";
const item = {
orderNumber: "SYNTH-ORDER-2", partNum: "B-SYNTH-1", productName: "Synthetic GPU compute",
uom: "GPU PER HOUR", subLnStartSt: "30-Sep-2025", subLnEndDt: "27-May-2028",
usageMax: "-", rateCardDiscountPerc: "12.5", listPriceUsd: "4.3585",
netUnitPriceUsd: "0.9589", overagePriceUsd: "1.2501"
};
for (const family of ["chrome", "firefox"]) {
test(`${family}: rate card uses the active planNumber, OCICONS-TS and exact POST contract, with debug`, async () => {
const calls = [];
const app = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options });
if (url.endsWith("consumerTokenService")) return response([
{ tokenService: "ACCP-TS", uiaasHeader: { value: "synthetic-account" } },
{ tokenService: "OCICONS-TS", uiaasHeader: { label: "not-used", value: "synthetic-consumption" } }
]);
return response([item]);
} });
const auth = await app.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
const result = await app.dispatch({ type, planNumber: "PLAN-123", subPlanNum: "WRONG-ID", authorization: auth.authorizations.accountPlans });
assert.equal(result.ok, true);
assert.deepEqual(result.items, [item]);
assert.equal(result.planNumber, "PLAN-123");
assert.equal(calls[1].url, "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/ratecard/byPlan");
assert.equal(calls[1].options.method, "POST");
assert.equal(calls[1].options.credentials, "include");
assert.equal(calls[1].options.cache, "no-store");
assert.deepEqual(JSON.parse(calls[1].options.body), { planNumber: "PLAN-123", recordsSize: 10000 });
assert.deepEqual(plain(calls[1].options.headers), {
Accept: "application/json", "Content-Type": "application/json", "G2m-Authorization": "synthetic-consumption"
});
const entry = result.debugRequests[0];
assert.equal(entry.name, "requestRatecardByPlan");
assert.equal(entry.httpStatus, 200);
assert.equal(entry.status, "success");
assert.ok(entry.startedAt && entry.completedAt);
assert.equal(typeof entry.durationMs, "number");
assert.deepEqual(JSON.parse(entry.requestBody), { planNumber: "PLAN-123", recordsSize: 10000 });
assert.match(entry.responseBody, /Synthetic GPU/);
const content = loadApplication("content");
assert.doesNotMatch(content.debugContentLog.formatHeaderBlock(entry.requestHeaders), /synthetic-consumption/);
assert.equal(content.debugContentView.getRequestCategory(entry), "consumption");
});
}
test("rate card fails locally for missing identifiers/credentials, HTTP, network, JSON and collection errors without retries", async () => {
for (const failure of ["id", "token", "401", "403", "network", "json", "shape", "item"]) {
let calls = 0;
const app = loadBackground({ fetch: async () => {
calls += 1;
if (["401", "403"].includes(failure)) return response({}, Number(failure));
if (failure === "network") throw new Error("Synthetic network failure");
if (failure === "json") return new Response("<html>fallback</html>");
return response(failure === "shape" ? {} : [null]);
} });
const result = await app.dispatch({ type, planNumber: failure === "id" ? "" : "A",
authorization: { value: failure === "token" ? "" : "synthetic" } });
assert.equal(result.ok, false, failure);
assert.ok(result.error, failure);
assert.equal(calls, ["id", "token"].includes(failure) ? 0 : 1, failure);
if (calls) assert.equal(result.debugRequests[0].status, "failed", failure);
}
});
test("all eleven columns are searchable, percentages and precise prices are formatted, and sorting is typed", () => {
const model = loadApplication("content").ratecardModel;
assert.equal(model.columns.length, 11);
for (const column of model.columns) {
assert.equal(model.selectItems([item], item[column.key]).length, 1, column.key);
assert.equal(model.selectItems([item], model.formatValue(item, column)).length, 1, column.key);
}
const format = (key, value) => model.formatValue({ [key]: value }, model.columns.find(column => column.key === key));
assert.equal(format("netUnitPriceUsd", "0.9589"), "0.9589");
assert.equal(format("listPriceUsd", 0), "0.0000");
assert.equal(format("rateCardDiscountPerc", 12.5), "12.5%");
assert.equal(format("usageMax", 0), "0");
assert.equal(format("subLnEndDt", null), "-");
assert.equal(model.selectItems([{ ...item, productName: "Synthet\u00edc" }], "synthetic").length, 1);
assert.equal(model.selectItems([{ ...item, hiddenMetadata: "secretmatch" }], "secretmatch").length, 0);
const rows = [
{ ...item, netUnitPriceUsd: "10", subLnStartSt: "01-Jan-2027" },
{ ...item, netUnitPriceUsd: "2", subLnStartSt: "31-Dec-2026" },
{ ...item, netUnitPriceUsd: null, subLnStartSt: null }
];
assert.deepEqual(plain(model.selectItems(rows, "", "netUnitPriceUsd").map(row => row.netUnitPriceUsd)), ["2", "10", null]);
assert.deepEqual(plain(model.selectItems(rows, "", "netUnitPriceUsd", "desc").map(row => row.netUnitPriceUsd)), ["10", "2", null]);
assert.deepEqual(plain(model.selectItems(rows, "", "subLnStartSt").map(row => row.subLnStartSt)), ["31-Dec-2026", "01-Jan-2027", null]);
assert.deepEqual(rows.map(row => row.netUnitPriceUsd), ["10", "2", null], "sorting does not mutate responses");
});
function setup() {
const document = { activeElement: null, documentElement: {} };
const walk = node => [node, ...node.children.flatMap(walk)];
class Element {
constructor(tag) {
this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.style = {}; this.dataset = {};
this.text = ""; this.value = ""; this.inert = false;
this.classList = { add: name => { this.className = `${this.className || ""} ${name}`; } };
}
append(...nodes) { nodes.forEach(node => { node.parent = this; }); this.children.push(...nodes); }
set textContent(value) { this.text = String(value); this.children.forEach(node => { node.parent = null; }); this.children = []; }
get textContent() { return this.text; }
setAttribute(key, value) { this.attributes[key] = value; }
getAttribute(key) { return this.attributes[key]; }
addEventListener(name, callback) { this.listeners[name] = callback; }
focus(options) { document.activeElement = this; this.focusOptions = options; }
remove() { if (this.parent) this.parent.children = this.parent.children.filter(node => node !== this); this.parent = null; }
get isConnected() { return this === root || !!this.parent?.isConnected; }
querySelectorAll(selector) {
return walk(this).slice(1).filter(node => selector.split(",").some(part => {
const value = part.trim();
if (value.startsWith(".")) return (node.className || "").split(" ").includes(value.slice(1));
const attr = value.match(/^\[([^=]+)='([^']+)'\]$/);
return attr ? node.attributes[attr[1]] === attr[2] : node.tag === value;
}));
}
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
}
const root = new Element("main");
document.createElement = tag => new Element(tag);
document.createElementNS = (_, tag) => new Element(tag);
document.createTextNode = text => { const node = new Element("#text"); node.textContent = text; return node; };
document.getElementById = id => walk(root).find(node => node.id === id) || null;
const timers = new Map();
const frames = new Map();
let id = 0;
const app = loadApplication("content", { document, window: { addEventListener() {} },
requestAnimationFrame: fn => { frames.set(++id, fn); return id; }, cancelAnimationFrame: key => frames.delete(key),
setTimeout: fn => { timers.set(++id, fn); return id; }, clearTimeout: key => timers.delete(key) });
root.id = app.contracts.MODAL_ID;
app.consumptionPlanDetailController.loadPlanDetailForPanel = () => {};
app.consumptionTrendController.loadConsumptionTrendForPanel = () => {};
const plans = [{ contractId: "CONTRACT-A", planNumber: "PLAN-A", subPlanNum: "SUB-A", bookingCustomer: "Booking A", soldToPartyName: "Sold A", hdrStatusCode: "ACTIVE", startDate: "23-Aug-2024", endDate: "22-Aug-2028" },
{ contractId: "CONTRACT-B", planNumber: "PLAN-B", subPlanNum: "SUB-B", bookingCustomer: "Booking B", soldToPartyName: "Sold B", hdrStatusCode: "EXPIRED", startDate: "01-Jan-2020", endDate: "01-Jan-2024" }];
const opportunity = { OptyNumber: "SYNTH", CustomerAccount: { PartyUniqueName: "Synthetic Customer" } };
app.consumptionPlansView.openAccountPlansModal(opportunity, [], "loading");
app.consumptionPlansView.openAccountPlansModal(opportunity, plans);
timers.clear();
frames.clear();
const logs = [], requests = [];
app.debugContentLog.appendBackgroundRequestLog = entries => logs.push(entries);
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ accountPlans: { value: "synthetic-plan" } });
app.platformContentMessages.sendRuntimeMessage = message => new Promise(resolve => requests.push({ message, resolve }));
const find = suffix => root.querySelector(`.opportunities-extension-ratecard-${suffix}`);
const buttons = root.querySelectorAll(".opportunities-extension-ratecard-button");
const nextFrame = () => { const callbacks = [...frames.values()]; frames.clear(); callbacks.forEach(fn => fn()); };
return { app, root, document, walk, timers, frames, nextFrame, plans, requests, logs, buttons, find };
}
const tick = () => new Promise(resolve => setImmediate(resolve));
test("Rate card sits beside Contract ID, opens a skeleton, searches and sorts without HTML injection", async () => {
const { app, root, buttons, find, document, requests, logs } = setup();
assert.equal(buttons.length, 2);
assert.equal(buttons[0].textContent, "Rate Card");
assert.equal(buttons[0].parent.parent.className, "opportunities-extension-contract-row");
assert.equal(buttons[0].parent.children[0].textContent, "CONTRACT-A");
const opening = buttons[0].listeners.click();
assert.equal(find("drawer").attributes["aria-busy"], "true");
assert.equal(root.querySelectorAll(".opportunities-extension-ratecard-skeleton").length, 77);
assert.equal(document.activeElement, find("search"));
await tick();
assert.deepEqual(plain(requests[0].message), { type, planNumber: "SUB-A", authorization: { value: "synthetic-plan" } });
const parentHeader = root.querySelector(".opportunities-extension-account-plans-header");
assert.equal(find("header").querySelector("h2").textContent, "Rate Card");
assert.equal(parentHeader.querySelector("h2").textContent, "Cloud Consumption");
assert.equal(find("header").querySelector("p").textContent, parentHeader.querySelector("p").textContent);
assert.equal(find("header").querySelector("p").textContent, "Synthetic Customer");
assert.notEqual(find("header").querySelector("h2").id, parentHeader.querySelector("h2").id);
assert.ok(find("header").className.includes("opportunities-extension-account-plans-header"));
const details = find("plan-details");
assert.deepEqual(details.querySelectorAll("dt").map(node => node.textContent), ["Booking Customer", "Sold To Party Name", "Contract ID", "Header Status", "Start Date", "End Date"]);
assert.deepEqual(details.querySelectorAll("dd").map(node => node.textContent), ["Booking A", "Sold A", "CONTRACT-A", "ACTIVE", "23-Aug-2024", "22-Aug-2028"]);
assert.equal(details.querySelectorAll("button").length, 0, "no recursive Rate Card action inside its own drawer");
const unsafe = "<img src=x onerror=bad()>";
requests[0].resolve({ ok: true, items: [{ ...item, netUnitPriceUsd: "10", productName: unsafe }, item], debugRequests: [{ name: "requestRatecardByPlan" }] });
await opening;
assert.equal(find("drawer").attributes["aria-busy"], "false");
assert.equal(find("count").textContent, "2 of 2 items");
assert.equal(root.querySelectorAll("img").length, 0);
const table = find("table");
assert.equal(table.querySelector("tbody").children[0].children[2].textContent, unsafe);
assert.equal(logs[0][0].name, "requestRatecardByPlan");
const priceSort = table.querySelectorAll("th")[9];
priceSort.children[0].listeners.click();
assert.equal(priceSort.attributes["aria-sort"], "ascending");
assert.equal(table.querySelector("tbody").children[0].children[9].textContent, "0.9589");
priceSort.children[0].listeners.click();
assert.equal(priceSort.attributes["aria-sort"], "descending");
assert.equal(table.querySelector("tbody").children[0].children[9].textContent, "10.0000");
find("search").value = "12.5%";
find("search").listeners.input();
assert.equal(find("count").textContent, "2 of 2 items");
find("search").value = "no match";
find("search").listeners.input();
assert.equal(find("count").textContent, "0 of 2 items");
assert.equal(find("notice").children[0].textContent, "No matching rate card items.");
find("header").querySelector("button").listeners.click();
assert.equal(root.querySelector(".opportunities-extension-account-plans-modal").hidden, undefined, "drawer close keeps Cloud Consumption open");
app.ratecardView.closeRatecardDrawer({ immediate: true });
});
test("quick clicks share a request, tab changes discard stale results, closing restores focus and keyboard stays inside", async () => {
const { app, root, buttons, requests, find, document, timers } = setup();
const first = buttons[0].listeners.click();
const repeat = buttons[0].listeners.click();
await tick();
assert.equal(requests.length, 1);
root.querySelectorAll("[role='tab']")[1].listeners.click();
assert.equal(find("overlay"), null);
const second = buttons[1].listeners.click();
await tick();
assert.equal(requests[1].message.planNumber, "SUB-B");
assert.deepEqual(find("plan-details").querySelectorAll("dd").map(node => node.textContent), ["Booking B", "Sold B", "CONTRACT-B", "EXPIRED", "01-Jan-2020", "01-Jan-2024"]);
requests[1].resolve({ ok: true, items: [item] });
await second;
requests[0].resolve({ ok: true, items: [{ ...item, productName: "WRONG PLAN" }] });
await Promise.all([first, repeat]);
assert.equal(find("table").querySelector("tbody").children[0].children[2].textContent, item.productName);
const background = root.querySelector(".opportunities-extension-account-plans-dialog");
assert.equal(background.inert, true);
const overlay = find("overlay");
const close = find("header").querySelector("button");
close.focus();
let prevented = 0, stopped = 0;
overlay.listeners.keydown({ key: "Tab", shiftKey: true, preventDefault() { prevented++; } });
assert.equal(document.activeElement, find("table").querySelectorAll("button")[10]);
overlay.listeners.keydown({ key: "Tab", preventDefault() { prevented++; } });
assert.equal(document.activeElement, close);
overlay.listeners.keydown({ key: "Escape", preventDefault() { prevented++; }, stopPropagation() { stopped++; } });
assert.equal(prevented, 3);
assert.equal(stopped, 1);
assert.equal(overlay.dataset.closing, "true");
assert.equal(document.activeElement, buttons[1]);
assert.equal(background.inert, false);
assert.equal(timers.size, 1);
const reopening = buttons[1].listeners.click();
assert.equal(timers.size, 0);
assert.equal(overlay.isConnected, false);
await tick();
app.consumptionPlansView.closeAccountPlansModal();
assert.equal(find("overlay"), null);
requests[2].resolve({ ok: true, items: [item] });
await reopening;
assert.equal(find("overlay"), null, "late responses never reopen a closed drawer");
});
test("missing subPlanNum does not use another identifier; errors are local, empty results and retry on reopening work", async () => {
const { app, plans, buttons, requests, find } = setup();
delete plans[0].subPlanNum;
await buttons[0].listeners.click();
assert.match(find("notice").children[0].textContent, /Plan number is unavailable/);
assert.equal(requests.length, 0);
const failed = buttons[1].listeners.click();
await tick();
requests[0].resolve({ ok: false, error: "Synthetic 401", debugRequests: [] });
await failed;
assert.equal(find("notice").children[0].attributes.role, "alert");
assert.equal(find("notice").children[0].textContent, "Synthetic 401");
const retry = buttons[1].listeners.click();
await tick();
requests[1].resolve({ ok: true, items: [] });
await retry;
assert.equal(find("notice").children[0].textContent, "No rate card items found for this plan.");
find("overlay").listeners.click({ target: find("overlay") });
app.ratecardView.closeRatecardDrawer({ immediate: true });
assert.equal(find("overlay"), null);
});
test("opening a plan without planNumber sends its subPlanNum as the payload planNumber", async () => {
const { plans, buttons, requests } = setup();
delete plans[0].planNumber;
const opening = buttons[0].listeners.click();
await tick();
assert.equal(requests.length, 1);
assert.equal(requests[0].message.planNumber, "SUB-A");
requests[0].resolve({ ok: true, items: [] });
await opening;
});
test("drawer starts offscreen, opens after a painted frame and never scrolls when focused", () => {
const { app, buttons, plans, find, frames, nextFrame } = setup();
app.ratecardView.openRatecardDrawer(plans[0], buttons[0]);
const overlay = find("overlay");
assert.equal(overlay.dataset.open, "false");
assert.equal(find("search").focusOptions.preventScroll, true);
nextFrame();
assert.equal(overlay.dataset.open, "false", "offscreen position gets one full paint");
nextFrame();
assert.equal(overlay.dataset.open, "true");
assert.equal(frames.size, 0);
app.ratecardView.closeRatecardDrawer({ immediate: true });
assert.equal(buttons[0].focusOptions.preventScroll, true);
for (const painted of [false, true]) {
app.ratecardView.openRatecardDrawer(plans[0], buttons[0]);
const cancelled = find("overlay");
if (painted) nextFrame();
app.ratecardView.closeRatecardDrawer({ immediate: true });
assert.equal(frames.size, 0);
nextFrame();
assert.equal(cancelled.dataset.open, "false", "closing cancels pending entry frames");
assert.equal(cancelled.isConnected, false);
}
});
test("drawer CSS supports both themes, reduced motion, bounded scrolling and separate header icon space", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/ratecard/styles.css"), "utf8");
assert.match(css, /data-theme="dark"/);
const buttonStyle = css.match(/\.opportunities-extension-ratecard-button \{([^}]+)\}/)[1];
assert.match(buttonStyle, /display: inline-flex/);
assert.match(buttonStyle, /align-items: center/);
assert.match(buttonStyle, /height: 28px/);
assert.match(buttonStyle, /line-height: 20px/);
assert.match(css, /\.opportunities-extension-contract-ratecard > span \{\s*line-height: 20px/);
assert.match(css, /prefers-reduced-motion/);
assert.match(css, /translateX\(100%\)/);
assert.match(css, /transition: transform 320ms/);
assert.match(css, /\[data-open="true"\] \.opportunities-extension-ratecard-drawer \{ transform: translateX\(0\); \}/);
assert.match(css, /overflow: clip/, "the overlay cannot be scrolled by focus during entry");
assert.doesNotMatch(css, /opportunities-ratecard-(?:fade|slide|backdrop)-in/);
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
assert.match(css, /min-width: 1100px/);
assert.match(css, /grid-template-rows: auto auto auto minmax\(0, 1fr\)/);
assert.doesNotMatch(css, /\.opportunities-extension-account-plans-dialog\s*\{/);
});