Implemented the Plan Detail view to display consumption overages or forfeitures

This commit is contained in:
2026-09-22 19:17:53 -03:00
parent ea164cdcd6
commit 9ac5ffb11d
22 changed files with 1221 additions and 25 deletions

View File

@@ -31,13 +31,30 @@ Do not edit files under `dist` directly. Both targets use Manifest V3, with a
Chromium service worker and Firefox background scripts. The build composes classic
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
context. It serializes `src/content/styles/redwood.css` followed by the scoped
`src/features/debug/styles.css`, `src/features/opportunities/styles.css` and
`src/features/install-base/styles.css` and `src/features/service-requests/styles.css` at
`src/features/debug/styles.css`, `src/features/opportunities/styles.css`,
`src/features/install-base/styles.css`, `src/features/service-requests/styles.css`
and `src/features/consumption/plan-detail.css` at
build time. There is no
runtime module loader, browser-side eval, ES-module requirement or new dependency.
## Tests and Maintenance
Cloud Consumption loads `requestConsumptionPlanDetail` for the active plan after
account plans arrive, independently of the chart. Selecting another plan loads
its `subPlanNum` on demand. The GET uses `active=true` and the raw OCICONS-TS
`uiaasHeader.value` in `G2m-Authorization`, through the existing consumer session.
Projection badges align with the chart container's left edge, while the
Quarterly/Monthly/Weekly/Daily controls stay on the right of the same header row:
red Forfeit, blue Overage.
Hover or keyboard focus reveals an interactive tooltip; clicking pins it for
access to the Order number links. Escape, Close, outside clicks, tab changes and
modal closure dismiss it. Each root line retains its nested `clines` details.
Only HTTPS order links on `accountdashboard.oracle.com` become clickable.
Amounts use the requested non-LC fields, formatted without inferring a local
currency. Details are kept only in the current modal, with in-flight deduplication
and local loading/empty/error states. Failed details do not block the chart.
The existing Consumption debug category includes this request.
The Service Requests action sits immediately after Installed Base below Customer.
Its dialog opens with skeleton rows and lists open SRs for the selected customer.
The background first calls `requestAccountSummary` with ACCP-TS, then uses its

View File

@@ -13,7 +13,8 @@ function createMessageTypes() {
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail"
});
}
@@ -623,7 +624,32 @@ function createSpaConsumption({
}
}
return { requestConsumptionTrend };
async function requestConsumptionPlanDetail(message) {
const debugRequests = [];
try {
const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : "";
const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization");
if (!subPlanNum) throw new Error("Sub plan number is required for plan details.");
if (!authorization.value) throw new Error("OCICONS-TS authorization is unavailable for plan details.");
const url = `https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/plandetail/${encodeURIComponent(subPlanNum)}?active=true`;
const items = await fetchJson(url, {
method: "GET", credentials: "include", cache: "no-store",
headers: { Accept: "application/json", "G2m-Authorization": authorization.value }
}, "requestConsumptionPlanDetail", debugRequests);
const validLines = (lines, depth = 0) => Array.isArray(lines) && depth <= 20 && lines.every(line =>
line && typeof line === "object" && !Array.isArray(line)
&& (line.clines === undefined || line.clines === null || validLines(line.clines, depth + 1)));
if (!validLines(items)) throw new Error("requestConsumptionPlanDetail returned invalid plan details.");
return { ok: true, subPlanNum, items, debugRequests };
} catch (error) {
const entry = debugRequests[debugRequests.length - 1];
if (entry && entry.status === "success") { entry.status = "failed"; entry.error = error.message; }
error.debugRequests = debugRequests;
throw error;
}
}
return { requestConsumptionTrend, requestConsumptionPlanDetail };
}
// Source: src/services/spa/response-shapes.js
@@ -1580,6 +1606,7 @@ function registerBackgroundMessages({
requestCustomerServiceRequest,
requestConsumerTokenService,
requestConsumptionTrend,
requestConsumptionPlanDetail,
requestOpptyProducts,
runtimeApi
}) {
@@ -1687,6 +1714,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}
if (message.type === messageTypes.requestConsumptionPlanDetail) {
requestConsumptionPlanDetail(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load consumption plan details.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestConsumptionTrend) {
requestConsumptionTrend(message)
.then(sendResponse)
@@ -1821,10 +1856,11 @@ function createBackgroundApplication(overrides = {}) {
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
const requestConsumptionPlanDetail = (...args) => spaConsumption.requestConsumptionPlanDetail(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
function start() {
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,8 @@ function createMessageTypes() {
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail"
});
}
@@ -623,7 +624,32 @@ function createSpaConsumption({
}
}
return { requestConsumptionTrend };
async function requestConsumptionPlanDetail(message) {
const debugRequests = [];
try {
const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : "";
const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization");
if (!subPlanNum) throw new Error("Sub plan number is required for plan details.");
if (!authorization.value) throw new Error("OCICONS-TS authorization is unavailable for plan details.");
const url = `https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/plandetail/${encodeURIComponent(subPlanNum)}?active=true`;
const items = await fetchJson(url, {
method: "GET", credentials: "include", cache: "no-store",
headers: { Accept: "application/json", "G2m-Authorization": authorization.value }
}, "requestConsumptionPlanDetail", debugRequests);
const validLines = (lines, depth = 0) => Array.isArray(lines) && depth <= 20 && lines.every(line =>
line && typeof line === "object" && !Array.isArray(line)
&& (line.clines === undefined || line.clines === null || validLines(line.clines, depth + 1)));
if (!validLines(items)) throw new Error("requestConsumptionPlanDetail returned invalid plan details.");
return { ok: true, subPlanNum, items, debugRequests };
} catch (error) {
const entry = debugRequests[debugRequests.length - 1];
if (entry && entry.status === "success") { entry.status = "failed"; entry.error = error.message; }
error.debugRequests = debugRequests;
throw error;
}
}
return { requestConsumptionTrend, requestConsumptionPlanDetail };
}
// Source: src/services/spa/response-shapes.js
@@ -1580,6 +1606,7 @@ function registerBackgroundMessages({
requestCustomerServiceRequest,
requestConsumerTokenService,
requestConsumptionTrend,
requestConsumptionPlanDetail,
requestOpptyProducts,
runtimeApi
}) {
@@ -1687,6 +1714,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}
if (message.type === messageTypes.requestConsumptionPlanDetail) {
requestConsumptionPlanDetail(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load consumption plan details.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestConsumptionTrend) {
requestConsumptionTrend(message)
.then(sendResponse)
@@ -1821,10 +1856,11 @@ function createBackgroundApplication(overrides = {}) {
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
const requestConsumptionPlanDetail = (...args) => spaConsumption.requestConsumptionPlanDetail(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
function start() {
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,

File diff suppressed because one or more lines are too long

View File

@@ -32,6 +32,7 @@ different from extension storage and is not a safe place for credentials.
| Content | Opportunity query and presentation | Filter sets, sorting, search, request versions, column formatters, dashboard animation |
| Content | Products UI | Current request ID, product map, expanded rows, progress, last update |
| Content | Consumption UI | Plan state, per-plan/period trend cache, chart navigation and selected series |
| Content | Plan projection details | Per-panel WeakMap, shared in-flight requests, nested tooltip DOM, temporary pointer/resize listeners cleaned up on dismiss/tab change/modal close |
| Content | Service Requests UI | Customer-specific in-flight queries, skeleton/table modal, stale-response guards; no persistent cache |
| Content | AI summary | Per-opportunity in-flight state and summary; fresh generation on expansion |
| Content | Authentication/debug | General and scoped token promises, SPA authorizations, request logs, visibility flag |
@@ -56,7 +57,7 @@ Production tokens are held in memory, not deliberately persisted by the extensio
| Fusion general | GET `/fscmRestApi/tokenrelay` | Opportunity POST and product GET Bearer | Content promise/cache; no expiry-based refresh equivalent to Sales Common |
| Sales Common | GET `/fscmRestApi/tokenrelay?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F` | AI summary Bearer | In-memory `expires_in`, shared in-flight acquisition; at most one summary retry after 401/403 |
| ACCP-TS | SPA consumer service `uiaasHeader.value` | Account summary authorization | Retrieved with the consumer service response; expiry recorded, not automatically acted on |
| OCICONS-TS | SPA consumer service `uiaasHeader.value` | Account plans and consumption trend authorization | Same service response, distinct credential from ACCP-TS |
| OCICONS-TS | SPA consumer service `uiaasHeader.value` | Account plans, consumption trend and plan detail authorization | Same service response, distinct credential from ACCP-TS |
| SPA-ORANGE | SPA consumer service `uiaasHeader.value` | Customer service requests, fixed `custsr-auth` header with raw value, no Bearer prefix; ignores `uiaasHeader.label` | Optional at consumer acquisition; required only when opening Service Requests; existing in-memory session rules |
Fusion calls use `https://eeho.fa.us2.oraclecloud.com`; SPA calls use
@@ -73,6 +74,7 @@ operation-specific headers rather than unifying all calls under a default token.
| requestCustomerServiceRequest | GET `provider-proxy/g2m-custsr-provider/service/oscSr/{accountSummaryId}` | SPA-ORANGE; summary `id`, not `registryId`; encoded filter `{hierarchy:"single",status:"open"}`, creationDate descending, pages of 40 using totalResults/count/offset |
| requestAccountPlans | POST `provider-proxy/g2m-consumption/service/v1/account/accountPlans/{id}` | OCICONS-TS; original hierarchy/products/language/planStatus payload |
| requestConsumptionService{Period} | GET `provider-proxy/g2m-consumption/service/v1/plan/trend/{subPlanNum}?quarter=All&timePeriod={period}` | G2m-Authorization; quarterly/monthly/weekly/daily; weekly default |
| requestConsumptionPlanDetail | GET `provider-proxy/g2m-consumption/service/v1/plan/plandetail/{subPlanNum}?active=true` | Raw OCICONS-TS in G2m-Authorization; array of lines with optional nested clines; no retry or persisted cache |
SPA parsing removes BOM and the existing anti-JSON-hijacking prefixes before
JSON.parse. Content uses response.json directly. Do not silently unify these behaviors.
@@ -97,6 +99,7 @@ was silently changed during extraction.
| requestCustomerServiceRequest | partyNumber, authorizations.accountSummary/customerServiceRequests | ok, items (consolidated response.objects), debugRequests; error envelope on failure |
| requestAccountPlans | partyNumber, authorizations | ok, accountPlanId, plans, debugRequests |
| requestConsumptionTrend | subPlanNum, period, authorization | ok, subPlanNum, period, items, debugRequests |
| requestConsumptionPlanDetail | subPlanNum, authorization | ok, subPlanNum, items (including clines), debugRequests; error envelope on failure |
SPA failures return `{ok:false,error,debugRequests}`. Progress states include
`running`, `complete` and `complete-with-errors`; content additionally tracks idle

View File

@@ -16,7 +16,7 @@ function composeScript(target, { bootstrap = true } = {}) {
return `// Source: ${file}\n${source}`;
});
if (target === "content") {
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"]
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"]
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
}

View File

@@ -45,6 +45,8 @@ module.exports = {
"src/features/service-requests/action.js",
"src/features/service-requests/view.js",
"src/features/consumption/plans-view.js",
"src/features/consumption/plan-detail-controller.js",
"src/features/consumption/plan-detail-view.js",
"src/features/consumption/trend-controller.js",
"src/features/consumption/chart.js",
"src/features/consumption/model.js",

View File

@@ -113,10 +113,11 @@ function createBackgroundApplication(overrides = {}) {
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
const requestConsumptionPlanDetail = (...args) => spaConsumption.requestConsumptionPlanDetail(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
function start() {
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,

View File

@@ -8,6 +8,7 @@ function registerBackgroundMessages({
requestCustomerServiceRequest,
requestConsumerTokenService,
requestConsumptionTrend,
requestConsumptionPlanDetail,
requestOpptyProducts,
runtimeApi
}) {
@@ -115,6 +116,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}
if (message.type === messageTypes.requestConsumptionPlanDetail) {
requestConsumptionPlanDetail(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load consumption plan details.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestConsumptionTrend) {
requestConsumptionTrend(message)
.then(sendResponse)

View File

@@ -361,12 +361,26 @@ function createContentApplication(overrides = {}) {
showCopyConfirmation: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
environment
});
const consumptionPlanDetailView = createConsumptionPlanDetailView({
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
environment
});
const consumptionPlanDetailController = createConsumptionPlanDetailController({
messageTypes,
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
renderPlanDetailBadges: (...args) => consumptionPlanDetailView.renderPlanDetailBadges(...args),
environment
});
const consumptionPlansView = createConsumptionPlansView({
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
createConsumptionTrendSection: (...args) => consumptionTrendController.createConsumptionTrendSection(...args),
loadPlanDetailForPanel: (...args) => consumptionPlanDetailController.loadPlanDetailForPanel(...args),
closePlanDetailPopover: (...args) => consumptionPlanDetailView.closePlanDetailPopover(...args),
createSvgElement: (...args) => consumptionChart.createSvgElement(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
@@ -374,6 +388,7 @@ function createContentApplication(overrides = {}) {
environment
});
const consumptionTrendController = createConsumptionTrendController({
createPlanDetailSection: (...args) => consumptionPlanDetailController.createPlanDetailSection(...args),
messageTypes,
CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS,
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
@@ -462,6 +477,8 @@ function createContentApplication(overrides = {}) {
serviceRequestsAction,
serviceRequestsView,
consumptionPlansView,
consumptionPlanDetailController,
consumptionPlanDetailView,
consumptionTrendController,
consumptionChart,
consumptionModel,

View File

@@ -0,0 +1,63 @@
function createConsumptionPlanDetailController({ messageTypes, requestConsumerTokenServiceOnce, sendRuntimeMessage,
appendBackgroundRequestLog, renderPlanDetailBadges, environment }) {
const { document } = environment;
const states = new WeakMap();
const pending = new Map();
function createPlanDetailSection(plan) {
const section = document.createElement("div");
section.className = "opportunities-extension-plan-detail-badges";
section.dataset.subPlanNum = String(plan.subPlanNum || "").trim();
section.setAttribute("aria-live", "polite");
states.set(section, { status: "idle" });
return section;
}
async function request(subPlanNum) {
const authorizations = await requestConsumerTokenServiceOnce();
const response = await sendRuntimeMessage({ type: messageTypes.requestConsumptionPlanDetail, subPlanNum,
authorization: authorizations && authorizations.accountPlans });
appendBackgroundRequestLog(response?.debugRequests);
if (!response?.ok) throw new Error(response?.error || "Unable to load consumption plan details.");
return response.items;
}
function loadPlanDetailForPanel(panel, plan) {
const section = panel?.querySelector(".opportunities-extension-plan-detail-badges");
if (!section) return Promise.resolve();
const state = states.get(section);
if (state?.status === "ready") return Promise.resolve();
if (state?.status === "loading") return state.promise;
const subPlanNum = String(plan.subPlanNum || "").trim();
section.textContent = "";
section.setAttribute("aria-busy", "true");
const skeleton = document.createElement("span");
skeleton.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-plan-detail-skeleton";
skeleton.setAttribute("role", "status");
skeleton.setAttribute("aria-label", "Loading plan projection");
section.append(skeleton);
const current = { status: "loading", promise: null };
states.set(section, current);
current.promise = (async () => {
try {
if (!subPlanNum) throw new Error("Sub plan number is unavailable.");
if (!pending.has(subPlanNum)) pending.set(subPlanNum, request(subPlanNum).finally(() => pending.delete(subPlanNum)));
const items = await pending.get(subPlanNum);
if (!section.isConnected || states.get(section) !== current) return;
current.status = "ready";
renderPlanDetailBadges(section, items);
} catch (error) {
if (!section.isConnected || states.get(section) !== current) return;
current.status = "error";
section.textContent = "";
const notice = document.createElement("span");
notice.className = "opportunities-extension-plan-detail-error";
notice.setAttribute("role", "alert");
notice.textContent = error.message || "Unable to load plan details.";
section.append(notice);
} finally {
if (states.get(section) === current) section.setAttribute("aria-busy", "false");
}
})();
return current.promise;
}
return { createPlanDetailSection, loadPlanDetailForPanel };
}

View File

@@ -0,0 +1,160 @@
function createConsumptionPlanDetailView({ createCloseIcon, environment }) {
const { document, window, setTimeout, clearTimeout } = environment;
let active = null;
let sequence = 0;
const fields = [
["pricingModel", "Pricing model"], ["lineStatus", "Line status"],
["startDate", "Start date"], ["endDate", "End date"],
["committedCredits", "Committed credits", true], ["consumed", "Consumed", true],
["consumptionL30Days", "Consumption last 30 days", true], ["monthsRemaining", "Months remaining"],
["projectedConsumption", "Projected consumption", true], ["projectedDelta", "Projected delta", true],
["orderId", "Order number"]
];
function node(tag, className, text) {
const result = document.createElement(tag);
if (className) result.className = className;
if (text !== undefined) result.textContent = text;
return result;
}
function statusBadge(record, tag = "span") {
const text = String(record.projectedStatus || "Unknown");
const badge = node(tag, "opportunities-extension-projection-badge", text);
badge.dataset.status = text.trim().toLowerCase();
return badge;
}
function orderLink(record, text) {
try {
const url = new URL(record.orderTrackerURL);
if (url.origin !== "https://accountdashboard.oracle.com" || url.username || url.password) return null;
const link = node("a", "", text);
link.href = url.href;
link.target = "_blank";
link.rel = "noopener noreferrer";
return link;
} catch { return null; }
}
function createLineDetails(record) {
const section = node("section", "opportunities-extension-projection-line");
const heading = node("h3");
heading.append(statusBadge(record));
section.append(heading);
const details = node("dl", "opportunities-extension-projection-fields");
fields.forEach(([key, label, numeric]) => {
const raw = record[key];
let text = raw === null || raw === undefined || raw === "" ? "-" : String(raw);
if (numeric && text !== "-" && Number.isFinite(Number(raw))) {
text = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(raw));
}
const group = node("div");
const value = node("dd");
const link = key === "orderId" && text !== "-" ? orderLink(record, text) : null;
if (link) value.append(link);
else value.textContent = text;
group.append(node("dt", "", label), value);
details.append(group);
});
section.append(details);
if (Array.isArray(record.clines) && record.clines.length) {
const children = node("div", "opportunities-extension-projection-children");
record.clines.forEach(child => children.append(createLineDetails(child)));
section.append(children);
}
return section;
}
function closePlanDetailPopover(restoreFocus = false) {
if (!active) return;
const previous = active;
active = null;
clearTimeout(previous.timer);
previous.popover.hidden = true;
previous.button.setAttribute("aria-expanded", "false");
document.removeEventListener("pointerdown", previous.outside, true);
window.removeEventListener("resize", previous.resize);
if (restoreFocus && previous.button.isConnected) {
previous.suppressFocus = true;
previous.button.focus();
previous.suppressFocus = false;
}
}
function positionPopover(entry) {
const rect = entry.button.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const width = Math.min(560, viewportWidth - 24);
const height = Math.min(520, viewportHeight - 24);
entry.popover.style.width = `${width}px`;
entry.popover.style.maxHeight = `${height}px`;
const below = viewportHeight - rect.bottom - 12;
const actualHeight = Math.min(entry.popover.getBoundingClientRect().height, height);
entry.popover.style.left = `${Math.max(12, Math.min(rect.left, viewportWidth - width - 12))}px`;
entry.popover.style.top = `${Math.max(12, below >= actualHeight ? rect.bottom + 4 : Math.min(rect.top - actualHeight - 4, viewportHeight - actualHeight - 12))}px`;
}
function createInteractiveBadge(record) {
const wrapper = node("div", "opportunities-extension-projection-wrapper");
const button = statusBadge(record, "button");
button.type = "button";
button.setAttribute("aria-label", `Projected status: ${button.textContent}. View plan details`);
button.setAttribute("aria-haspopup", "dialog");
button.setAttribute("aria-expanded", "false");
const popover = node("div", "opportunities-extension-projection-popover");
popover.id = `opportunities-extension-projection-${++sequence}`;
popover.hidden = true;
popover.setAttribute("role", "dialog");
popover.setAttribute("aria-label", "Consumption plan details");
popover.tabIndex = -1;
button.setAttribute("aria-controls", popover.id);
const dismiss = node("button", "opportunities-extension-projection-dismiss");
dismiss.type = "button";
dismiss.setAttribute("aria-label", "Close plan details");
dismiss.title = "Close plan details";
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => closePlanDetailPopover(true));
popover.append(dismiss, createLineDetails(record));
const entry = { button, popover, wrapper, pinned: false, timer: null, suppressFocus: false,
outside: event => { if (!wrapper.contains(event.target)) closePlanDetailPopover(); },
resize: () => positionPopover(entry) };
function open() {
if (active !== entry) {
closePlanDetailPopover();
entry.pinned = false;
active = entry;
document.addEventListener("pointerdown", entry.outside, true);
window.addEventListener("resize", entry.resize);
}
clearTimeout(entry.timer);
popover.hidden = false;
button.setAttribute("aria-expanded", "true");
positionPopover(entry);
}
wrapper.addEventListener("pointerenter", open);
wrapper.addEventListener("pointerleave", () => {
if (entry.pinned || wrapper.contains(document.activeElement)) return;
entry.timer = setTimeout(() => { if (active === entry) closePlanDetailPopover(); }, 180);
});
wrapper.addEventListener("focusin", () => { if (!entry.suppressFocus) open(); });
wrapper.addEventListener("focusout", event => { if (!wrapper.contains(event.relatedTarget) && active === entry) closePlanDetailPopover(); });
button.addEventListener("click", () => {
if (active === entry && entry.pinned) closePlanDetailPopover();
else { open(); entry.pinned = true; }
});
wrapper.addEventListener("keydown", event => {
if (event.key === "Escape" && active === entry) {
event.preventDefault();
event.stopPropagation();
closePlanDetailPopover(true);
}
});
wrapper.append(button, popover);
return wrapper;
}
function renderPlanDetailBadges(section, items) {
if (active && section.contains(active.button)) closePlanDetailPopover();
section.textContent = "";
if (!items.length) {
section.append(node("span", "opportunities-extension-projection-empty", "No projection data"));
return;
}
items.forEach(record => section.append(createInteractiveBadge(record)));
}
return { renderPlanDetailBadges, createLineDetails, closePlanDetailPopover };
}

View File

@@ -0,0 +1,47 @@
.opportunities-extension-plan-detail-badges { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.opportunities-extension-consumption-trend-header { align-items: center; justify-content: space-between; gap: 12px; }
.opportunities-extension-consumption-trend-header > .opportunities-extension-plan-detail-badges { min-width: 0; }
.opportunities-extension-plan-detail-skeleton { width: 64px; height: 20px; }
.opportunities-extension-projection-badge {
display: inline-flex; align-items: center; justify-content: center; border: 1px solid transparent;
padding: 3px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; line-height: 1.3;
color: #5f5a55; background: #eceae8; white-space: nowrap;
}
button.opportunities-extension-projection-badge { cursor: pointer; }
.opportunities-extension-projection-badge[data-status="forfeit"] { color: #a12424; background: #fbe5e5; }
.opportunities-extension-projection-badge[data-status="overage"] { color: #185a9d; background: #e0edfc; }
.opportunities-extension-projection-badge:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-projection-popover {
position: fixed; z-index: 8; box-sizing: border-box; padding: 14px; overflow: auto; overscroll-behavior: contain;
border: 1px solid #b9b4ae; border-radius: 4px; background: #ffffff; color: #312d2a;
box-shadow: 0 4px 16px #0003; font-size: 12px; line-height: 1.4; overflow-wrap: anywhere;
}
.opportunities-extension-projection-popover[hidden] { display: none; }
.opportunities-extension-projection-popover .opportunities-extension-projection-dismiss {
display: grid; place-items: center; width: 24px; height: 24px; margin-left: auto; margin-bottom: 8px; padding: 4px; border: 1px solid #b9b4ae;
border-radius: 4px; background: transparent; color: inherit; cursor: pointer; font-size: 12px;
}
.opportunities-extension-projection-dismiss svg { width: 14px; height: 14px; }
.opportunities-extension-projection-line h3 { margin: 0 0 8px; font-size: 12px; }
.opportunities-extension-projection-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 16px; margin: 0; }
.opportunities-extension-projection-fields > div { min-width: 0; }
.opportunities-extension-projection-fields dt { font-weight: 600; color: #5f5a55; font-size: 11px; }
.opportunities-extension-projection-fields dd { margin: 2px 0 0; font-variant-numeric: tabular-nums; }
.opportunities-extension-projection-fields a { color: #006d7a; text-decoration: underline; }
.opportunities-extension-projection-children { margin-top: 14px; padding-left: 12px; border-left: 2px solid #dedbd7; }
.opportunities-extension-projection-children > section + section { margin-top: 16px; }
.opportunities-extension-plan-detail-error { color: #a12424; font-size: 12px; }
.opportunities-extension-projection-empty { color: #5f5a55; font-size: 12px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-popover { color: #f1efec; background: #302e2b; border-color: #716b64; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-badge { color: #dedbd7; background: #484541; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-badge[data-status="forfeit"] { color: #ffb9b9; background: #682e32; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-badge[data-status="overage"] { color: #c5dfff; background: #224b76; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-fields dt,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-empty { color: #cec9c2; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-fields a { color: #76dbea; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-plan-detail-error { color: #ffb9b9; }
@media (max-width: 480px) { .opportunities-extension-projection-fields { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 480px) {
.opportunities-extension-consumption-trend-header > .opportunities-extension-consumption-periods { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (prefers-reduced-motion: reduce) { .opportunities-extension-plan-detail-skeleton { animation: none; } }

View File

@@ -4,6 +4,8 @@ function createConsumptionPlansView({
MODAL_ID,
createCloseIcon,
createConsumptionTrendSection,
loadPlanDetailForPanel,
closePlanDetailPopover,
createSvgElement,
formatOracleResponseDate,
getNestedValue,
@@ -28,6 +30,7 @@ function createConsumptionPlansView({
return;
}
closePlanDetailPopover();
existingModal?.remove();
const modal = document.createElement("section");
@@ -119,6 +122,7 @@ function createConsumptionPlansView({
panel.append(createAccountPlanDetails(plan), createConsumptionTrendSection(plan));
tab.addEventListener("click", () => {
closePlanDetailPopover();
tabList.querySelectorAll("[role='tab']").forEach((candidate) => {
const isSelected = candidate === tab;
candidate.setAttribute("aria-selected", String(isSelected));
@@ -133,6 +137,7 @@ function createConsumptionPlansView({
plan,
trendSection ? trendSection.dataset.period : DEFAULT_CONSUMPTION_TREND_PERIOD
);
loadPlanDetailForPanel(panel, plan);
});
tabList.append(tab);
panels.append(panel);
@@ -151,6 +156,7 @@ function createConsumptionPlansView({
if (firstPanel) {
loadConsumptionTrendForPanel(firstPanel, plans[0], DEFAULT_CONSUMPTION_TREND_PERIOD);
loadPlanDetailForPanel(firstPanel, plans[0]);
}
}
}
@@ -347,6 +353,7 @@ function createConsumptionPlansView({
}
function closeAccountPlansModal() {
closePlanDetailPopover();
const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
if (modal) {

View File

@@ -4,6 +4,7 @@ function createConsumptionTrendController({
DEFAULT_CONSUMPTION_TREND_PERIOD,
appendBackgroundRequestLog,
consumptionTrendState,
createPlanDetailSection,
renderConsumptionTrendChart,
requestConsumerTokenServiceOnce,
sendRuntimeMessage,
@@ -44,7 +45,7 @@ function createConsumptionTrendController({
const chart = document.createElement("div");
chart.className = "opportunities-extension-consumption-chart";
chart.setAttribute("aria-live", "polite");
header.append(periodControls);
header.append(createPlanDetailSection(plan), periodControls);
section.append(header, chart);
return section;
}

View File

@@ -24,7 +24,7 @@ function createDebugContentView({
if (entry.name === "requestGenerateAiSummary") return "ai";
if (entry.name === "requestInstallBase") return "installBase";
if (entry.name === "requestCustomerServiceRequest") return "serviceRequests";
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService)/.test(entry.name)) return "consumption";
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail)/.test(entry.name)) return "consumption";
return "other";
}

View File

@@ -54,5 +54,30 @@ function createSpaConsumption({
}
}
return { requestConsumptionTrend };
async function requestConsumptionPlanDetail(message) {
const debugRequests = [];
try {
const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : "";
const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization");
if (!subPlanNum) throw new Error("Sub plan number is required for plan details.");
if (!authorization.value) throw new Error("OCICONS-TS authorization is unavailable for plan details.");
const url = `https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/plandetail/${encodeURIComponent(subPlanNum)}?active=true`;
const items = await fetchJson(url, {
method: "GET", credentials: "include", cache: "no-store",
headers: { Accept: "application/json", "G2m-Authorization": authorization.value }
}, "requestConsumptionPlanDetail", debugRequests);
const validLines = (lines, depth = 0) => Array.isArray(lines) && depth <= 20 && lines.every(line =>
line && typeof line === "object" && !Array.isArray(line)
&& (line.clines === undefined || line.clines === null || validLines(line.clines, depth + 1)));
if (!validLines(items)) throw new Error("requestConsumptionPlanDetail returned invalid plan details.");
return { ok: true, subPlanNum, items, debugRequests };
} catch (error) {
const entry = debugRequests[debugRequests.length - 1];
if (entry && entry.status === "success") { entry.status = "failed"; entry.error = error.message; }
error.debugRequests = debugRequests;
throw error;
}
}
return { requestConsumptionTrend, requestConsumptionPlanDetail };
}

View File

@@ -10,6 +10,7 @@ function createMessageTypes() {
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail"
});
}

View File

@@ -0,0 +1,264 @@
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 detail = {
projectedStatus: "Forfeit", pricingModel: "Funded Allocation", lineStatus: "ACTIVE",
startDate: "17-Aug-2023", endDate: "16-Aug-2027", committedCredits: 7488782,
consumed: 4106739, consumptionL30Days: 152200, monthsRemaining: null,
projectedConsumption: null, projectedDelta: null, currencyCode: "BRL", orderId: "SYNTH-ORDER",
orderTrackerURL: "https://accountdashboard.oracle.com/app/udb/?appId=12#/ordertracker/orderNumber/SYNTH-ORDER",
clines: [{ projectedStatus: "Overage", pricingModel: "Funded Allocation", monthsRemaining: 0,
committedCredits: 100, consumed: 110, projectedConsumption: 120, projectedDelta: -20,
orderId: "SYNTH-CHILD", orderTrackerURL: "https://accountdashboard.oracle.com/app/udb/#/ordertracker/orderNumber/SYNTH-CHILD" }]
};
const type = "opportunitiesExtension.requestConsumptionPlanDetail";
for (const family of ["chrome", "firefox"]) {
test(`${family}: plan details use OCICONS value in G2m-Authorization, encoded subPlanNum, and 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: "unused-label", value: "synthetic-plan" } }
]);
return response([detail]);
} });
const auth = await app.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
const result = await app.dispatch({ type, subPlanNum: "plan/test", authorization: auth.authorizations.accountPlans });
assert.equal(result.ok, true);
assert.deepEqual(result.items, [detail]);
assert.equal(result.subPlanNum, "plan/test");
assert.equal(calls[1].url, "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/plandetail/plan%2Ftest?active=true");
assert.equal(calls[1].options.method, "GET");
assert.equal(calls[1].options.credentials, "include");
assert.equal(calls[1].options.cache, "no-store");
assert.equal(calls[1].options.body, undefined);
assert.deepEqual(plain(calls[1].options.headers), { Accept: "application/json", "G2m-Authorization": "synthetic-plan" });
assert.equal(result.debugRequests[0].name, "requestConsumptionPlanDetail");
assert.equal(result.debugRequests[0].httpStatus, 200);
const content = loadApplication("content");
assert.doesNotMatch(content.debugContentLog.formatHeaderBlock(result.debugRequests[0].requestHeaders), /synthetic-plan/);
assert.equal(content.debugContentView.getRequestCategory(result.debugRequests[0]), "consumption");
});
}
test("missing token/subPlanNum, HTTP, network, invalid JSON and malformed nested lines do not silently succeed", async () => {
for (const kind of ["token", "id", "http", "network", "json", "array", "clines"]) {
let calls = 0;
const app = loadBackground({ fetch: async () => {
calls += 1;
if (kind === "http") return response({}, 403);
if (kind === "network") throw new Error("Synthetic network failure");
if (kind === "json") return new Response("<html>fallback</html>");
return response(kind === "array" ? {} : [{ clines: {} }]);
} });
const result = await app.dispatch({ type, subPlanNum: kind === "id" ? "" : "test",
authorization: { value: kind === "token" ? "" : "synthetic-plan" } });
assert.equal(result.ok, false, kind);
assert.ok(result.error, kind);
assert.equal(calls, ["token", "id"].includes(kind) ? 0 : 1);
if (calls) assert.equal(result.debugRequests[0].status, "failed");
}
});
function createDom() {
const document = { activeElement: null, listeners: new Map(), documentElement: {} };
const window = { innerWidth: 420, innerHeight: 640, listeners: new Map() };
for (const target of [document, window]) {
target.addEventListener = (name, fn) => { if (!target.listeners.has(name)) target.listeners.set(name, new Set()); target.listeners.get(name).add(fn); };
target.removeEventListener = (name, fn) => target.listeners.get(name)?.delete(fn);
}
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.hidden = false; this.text = "";
this.classList = { add: name => { this.className = `${this.className || ""} ${name}`.trim(); } };
}
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, fn) { this.listeners[name] = fn; }
removeEventListener(name) { delete this.listeners[name]; }
focus() { document.activeElement = this; }
contains(node) { return !!node && walk(this).includes(node); }
remove() { if (this.parent) this.parent.children = this.parent.children.filter(child => child !== 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; }
getBoundingClientRect() { return this.tag === "button" ? { left: 350, top: 530, bottom: 550, height: 20 } : { height: 500 }; }
}
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();
let timerId = 0;
return { root, document, window, walk, timers,
setTimeout: fn => { timers.set(++timerId, fn); return timerId; }, clearTimeout: id => timers.delete(id) };
}
function setup() {
const dom = createDom();
const app = loadApplication("content", { ...dom, URL, Intl, requestAnimationFrame: () => {} });
dom.root.id = app.contracts.MODAL_ID;
app.debugContentLog.appendBackgroundRequestLog = () => {};
return { ...dom, app };
}
test("nested tooltip preserves values/null/zero and restricts order links to the expected HTTPS origin", () => {
const { app, root, walk } = setup();
app.consumptionPlanDetailView.renderPlanDetailBadges(root, [detail, { projectedStatus: "Other", pricingModel: "<img onerror=bad>", orderId: "bad", orderTrackerURL: "javascript:bad()" }]);
assert.equal(root.children.length, 2);
assert.equal(root.children[0].children[0].dataset.status, "forfeit");
const tooltip = root.children[0].children[1];
assert.equal(tooltip.hidden, true);
const nesting = tooltip.querySelector(".opportunities-extension-projection-children");
assert.ok(nesting);
assert.equal(nesting.querySelector(".opportunities-extension-projection-badge").dataset.status, "overage");
const terms = tooltip.querySelectorAll("dt");
assert.equal(terms.length, 22);
assert.ok(terms.some(term => term.textContent === "Order number"));
const values = tooltip.querySelectorAll("dd").map(value => value.textContent);
assert.ok(values.includes("7,488,782.00"));
assert.ok(values.includes("-20.00"));
assert.ok(values.includes("0"));
assert.ok(values.includes("-"));
assert.ok(values.includes("17-Aug-2023"));
assert.equal(root.querySelectorAll("a").length, 2);
assert.ok(root.querySelectorAll("a").every(link => link.target === "_blank" && link.rel === "noopener noreferrer"));
assert.ok(walk(root).some(node => node.textContent === "<img onerror=bad>"));
for (const orderTrackerURL of ["https://accountdashboard.oracle.com.evil.example/x", "https://user@accountdashboard.oracle.com/x", "http://accountdashboard.oracle.com/x"]) {
assert.equal(app.consumptionPlanDetailView.createLineDetails({ orderId: "test", orderTrackerURL }).querySelectorAll("a").length, 0);
}
});
test("hover, click, keyboard, outside dismissal and cleanup keep the popover within the viewport", () => {
const { app, root, document, window, timers } = setup();
app.consumptionPlanDetailView.renderPlanDetailBadges(root, [detail]);
const wrapper = root.children[0];
const [button, tooltip] = wrapper.children;
wrapper.listeners.pointerenter();
assert.equal(tooltip.hidden, false);
assert.equal(button.attributes["aria-expanded"], "true");
assert.ok(parseFloat(tooltip.style.left) + parseFloat(tooltip.style.width) <= window.innerWidth);
assert.ok(parseFloat(tooltip.style.top) >= 12);
wrapper.listeners.pointerleave();
assert.equal(timers.size, 1);
wrapper.listeners.pointerenter();
assert.equal(timers.size, 0);
button.listeners.click();
wrapper.listeners.pointerleave();
assert.equal(timers.size, 0, "click pins the tooltip so the order links remain usable");
let stopped = false;
wrapper.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() { stopped = true; } });
assert.equal(stopped, true);
assert.equal(tooltip.hidden, true);
assert.equal(document.activeElement, button);
assert.equal(document.listeners.get("pointerdown").size, 0);
assert.equal(window.listeners.get("resize").size, 0);
wrapper.listeners.focusin();
for (const listener of document.listeners.get("pointerdown")) listener({ target: root });
assert.equal(tooltip.hidden, true);
});
test("controller shares in-flight requests, preserves per-panel identity and ignores removed panels", async () => {
const { app, root, document } = setup();
const calls = [];
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ accountPlans: { value: "synthetic-plan" } });
app.platformContentMessages.sendRuntimeMessage = message => new Promise(resolve => calls.push({ message, resolve }));
const panel = document.createElement("section");
panel.append(app.consumptionPlanDetailController.createPlanDetailSection({ subPlanNum: "A" }));
root.append(panel);
const first = app.consumptionPlanDetailController.loadPlanDetailForPanel(panel, { subPlanNum: "A" });
const repeat = app.consumptionPlanDetailController.loadPlanDetailForPanel(panel, { subPlanNum: "A" });
assert.equal(first, repeat);
assert.equal(panel.children[0].attributes["aria-busy"], "true");
const other = document.createElement("section");
other.append(app.consumptionPlanDetailController.createPlanDetailSection({ subPlanNum: "B" }));
root.append(other);
const second = app.consumptionPlanDetailController.loadPlanDetailForPanel(other, { subPlanNum: "B" });
await new Promise(resolve => setImmediate(resolve));
assert.equal(calls.length, 2);
assert.deepEqual(plain(calls[0].message), { type, subPlanNum: "A", authorization: { value: "synthetic-plan" } });
calls[1].resolve({ ok: true, items: [{ projectedStatus: "Overage" }] });
await second;
panel.remove();
calls[0].resolve({ ok: true, items: [detail] });
await first;
assert.equal(other.querySelector(".opportunities-extension-projection-badge").textContent, "Overage");
assert.equal(panel.querySelector(".opportunities-extension-projection-badge"), null);
await app.consumptionPlanDetailController.loadPlanDetailForPanel(other, { subPlanNum: "B" });
assert.equal(calls.length, 2, "ready details remain in the current modal only");
});
test("Cloud Consumption loads details for the first tab and each newly selected plan, without delaying trends", () => {
const { app, root, document } = setup();
const calls = [];
const trends = [];
app.consumptionPlanDetailController.loadPlanDetailForPanel = (_, plan) => calls.push(plan.subPlanNum);
app.consumptionTrendController.loadConsumptionTrendForPanel = (_, plan) => trends.push(plan.subPlanNum);
app.consumptionPlansView.openAccountPlansModal({ OptyNumber: "SYNTH" }, [], "loading");
app.consumptionPlansView.openAccountPlansModal({ OptyNumber: "SYNTH" }, [{ subPlanNum: "A" }, { subPlanNum: "B" }], "ready");
assert.deepEqual(calls, ["A"]);
assert.deepEqual(trends, ["A"]);
const badges = root.querySelector(".opportunities-extension-plan-detail-badges");
const trendHeader = root.querySelector(".opportunities-extension-consumption-trend-header");
assert.equal(trendHeader.children[0], badges, "projection badges are left of the period controls");
assert.equal(trendHeader.children[1].className, "opportunities-extension-consumption-periods");
assert.deepEqual(trendHeader.children[1].children.map(button => button.textContent), ["Quarterly", "Monthly", "Weekly", "Daily"]);
assert.equal(root.querySelector(".opportunities-extension-account-plan-details").querySelector(".opportunities-extension-plan-detail-badges"), null);
app.consumptionPlanDetailView.renderPlanDetailBadges(badges, [detail]);
const wrapper = badges.children[0];
wrapper.listeners.pointerenter();
root.querySelectorAll("[role='tab']")[1].listeners.click();
assert.equal(wrapper.children[1].hidden, true, "switching tabs closes the previous tooltip");
assert.deepEqual(calls, ["A", "B"]);
assert.deepEqual(trends, ["A", "B"]);
assert.equal(root.querySelectorAll(".opportunities-extension-plan-detail-badges").length, 2);
app.consumptionPlansView.closeAccountPlansModal();
assert.equal(document.getElementById(app.contracts.ACCOUNT_PLANS_MODAL_ID).hidden, true);
assert.equal(document.listeners.get("pointerdown").size, 0);
});
test("a detail failure stays local and the next activation can retry and display the empty state", async () => {
const { app, root, document } = setup();
const panel = document.createElement("section");
const section = app.consumptionPlanDetailController.createPlanDetailSection({ subPlanNum: "A" });
panel.append(section);
root.append(panel);
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ accountPlans: { value: "synthetic-plan" } });
let count = 0;
app.platformContentMessages.sendRuntimeMessage = async () => ++count === 1
? { ok: false, error: "Synthetic detail failure" } : { ok: true, items: [] };
await app.consumptionPlanDetailController.loadPlanDetailForPanel(panel, { subPlanNum: "A" });
assert.equal(section.attributes["aria-busy"], "false");
assert.equal(section.children[0].attributes.role, "alert");
assert.equal(section.children[0].textContent, "Synthetic detail failure");
await app.consumptionPlanDetailController.loadPlanDetailForPanel(panel, { subPlanNum: "A" });
assert.equal(count, 2);
assert.equal(section.children[0].textContent, "No projection data");
});
test("plan projection CSS is scoped, has red/blue states in both themes and bounds only the tooltip scrolling", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/consumption/plan-detail.css"), "utf8");
assert.match(css, /data-status="forfeit"/);
assert.match(css, /data-status="overage"/);
assert.match(css, /data-theme="dark"/);
assert.match(css, /position: fixed/);
const headerRule = css.match(/\.opportunities-extension-consumption-trend-header \{([^}]+)\}/)[1];
assert.match(headerRule, /justify-content: space-between/, "badges align with the chart's left edge while period controls stay right");
assert.match(css, /prefers-reduced-motion/);
assert.doesNotMatch(css, /\.opportunities-extension-account-plans-dialog\s*\{/);
});

View File

@@ -28,6 +28,7 @@ test("request categories cover every current family and unknown requests stay vi
requestOpportunities: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai",
requestAccountSummary: "consumption", requestAccountPlans: "consumption",
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests",
requestConsumptionPlanDetail: "consumption",
requestConsumptionServiceQuarterly: "consumption", requestConsumptionServiceMonthly: "consumption",
requestConsumptionServiceWeekly: "consumption", requestConsumptionServiceDaily: "consumption", futureRequest: "other"
})) assert.equal(h.app.debugContentView.getRequestCategory({ name }), category, name);
@@ -94,7 +95,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"]
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"]
.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"].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"].map(read).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
});