Implemented filters for Service Requests and Actions
This commit is contained in:
@@ -4,12 +4,14 @@ const { loadApplication } = require("./support/application.cjs");
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
function setup(family, saved = {}, principal = "user@example.invalid") {
|
||||
const local = {
|
||||
get: async key => ({ [key]: saved[key] }),
|
||||
set: async values => Object.assign(saved, JSON.parse(JSON.stringify(values)))
|
||||
get: async key => key === null ? { ...saved } : ({ [key]: saved[key] }),
|
||||
set: async values => Object.assign(saved, JSON.parse(JSON.stringify(values))),
|
||||
remove: async keys => keys.forEach(key => delete saved[key])
|
||||
};
|
||||
const api = family === "firefox" ? { browser: { storage: { local } } } : {
|
||||
chrome: { runtime: {}, storage: { local: {
|
||||
get: (key, done) => local.get(key).then(done), set: (values, done) => local.set(values).then(done)
|
||||
get: (key, done) => local.get(key).then(done), set: (values, done) => local.set(values).then(done),
|
||||
remove: (keys, done) => local.remove(keys).then(done)
|
||||
} } }
|
||||
};
|
||||
const app = loadApplication("content", { ...api, document: { createElement: () => ({ setAttribute() {}, addEventListener() {} }) } });
|
||||
@@ -41,6 +43,34 @@ function setup(family, saved = {}, principal = "user@example.invalid") {
|
||||
return { app, calls, load, saved, local };
|
||||
}
|
||||
for (const family of ["chrome", "firefox"]) {
|
||||
test(`${family}: Current Quarter -> Next Quarter -> Current Quarter restores SR hours and filters without requests`, async () => {
|
||||
const h = setup(family);
|
||||
const currentStamp = Date.now() - 2000;
|
||||
const nextStamp = currentStamp + 1000;
|
||||
let hours = 2;
|
||||
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => {
|
||||
h.calls.push("sr");
|
||||
return [{ SrNumber: "SR1", ReportedHours_c: hours, OpportunityName_Id_c: { OptyNumber: "OP1" } }];
|
||||
};
|
||||
await h.load(currentStamp, false);
|
||||
hours = 8;
|
||||
await h.load(nextStamp, false);
|
||||
assert.equal(h.app.opportunityCrmServiceRequests.getSummary("OP1").reportedHours, 8);
|
||||
const count = h.calls.length;
|
||||
await h.load(currentStamp, true);
|
||||
assert.equal(h.calls.length, count);
|
||||
assert.equal(h.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "\u2705 SR (2h)");
|
||||
h.app.opportunitiesTable.renderOpportunitiesTable = () => {};
|
||||
h.app.activityFilters.toggle("sr", "under4");
|
||||
assert.equal(h.app.activityFilters.matches({ OptyNumber: "OP1" }), true);
|
||||
await h.load(nextStamp, true);
|
||||
assert.equal(h.calls.length, count);
|
||||
assert.equal(h.app.activityFilters.matches({ OptyNumber: "OP1" }), false);
|
||||
const reopened = setup(family, h.saved);
|
||||
await reopened.load(currentStamp, true);
|
||||
assert.deepEqual(reopened.calls, []);
|
||||
assert.equal(reopened.app.opportunityCrmServiceRequests.getSummary("OP1").reportedHours, 2);
|
||||
});
|
||||
test(`${family}: activities persist across instances with product timestamps and no credential storage`, async () => {
|
||||
const h = setup(family);
|
||||
const stamp = Date.now() - 1000;
|
||||
@@ -78,6 +108,26 @@ test("TTL follows product preferences, rejects stale/mismatched cache and stores
|
||||
await h.load(stamp + 1, true);
|
||||
assert.deepEqual(h.calls, []);
|
||||
});
|
||||
|
||||
test("versioned SR cache reads matching legacy entries and prunes expired snapshots only", async () => {
|
||||
const h = setup("firefox");
|
||||
const legacyKey = `opportunitiesExtension.activities.v1:${JSON.stringify(["user@example.invalid", "sr", "all"])}`;
|
||||
const stamp = Date.now() - 1000;
|
||||
h.saved[legacyKey] = { fetchedAt: stamp, items: [], hasNext: false };
|
||||
assert.ok(await h.app.activityCache.read("sr", "all", stamp));
|
||||
assert.equal(await h.app.activityCache.read("sr", "all", stamp + 1), null);
|
||||
const expiredStamp = Date.now() - 25 * 3600000;
|
||||
const expiredKey = `opportunitiesExtension.activities.v1:${JSON.stringify(["user@example.invalid", "sr", "all", expiredStamp])}`;
|
||||
h.saved[expiredKey] = { fetchedAt: expiredStamp, items: [], hasNext: false };
|
||||
h.saved.preferences = { keep: true };
|
||||
await h.app.activityCache.write("sr", "all", stamp + 1, []);
|
||||
assert.equal(h.saved[expiredKey], undefined);
|
||||
assert.ok(h.saved[legacyKey]);
|
||||
assert.equal(h.saved.preferences.keep, true);
|
||||
await h.app.activityCache.invalidate();
|
||||
assert.equal(await h.app.activityCache.read("sr", "all", stamp + 1), null);
|
||||
assert.equal(h.saved.preferences.keep, true);
|
||||
});
|
||||
test("storage failure does not lose live activity results and failures are never cached", async () => {
|
||||
const h = setup("firefox");
|
||||
h.local.get = async () => { throw new Error("Synthetic storage read failure"); };
|
||||
|
||||
201
tests/activityFilters.test.cjs
Normal file
201
tests/activityFilters.test.cjs
Normal file
@@ -0,0 +1,201 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { loadApplication } = require("./support/application.cjs");
|
||||
|
||||
class Element {
|
||||
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; }
|
||||
append(...nodes) { this.children.push(...nodes); }
|
||||
setAttribute(key, value) { this.attributes[key] = value; }
|
||||
addEventListener(key, handler) { this.listeners[key] = handler; }
|
||||
}
|
||||
function setup() {
|
||||
const app = loadApplication("content", { document: { createElement: tag => new Element(tag) } });
|
||||
app.currentUser.allowsCrmActivities = () => true;
|
||||
let renders = 0;
|
||||
app.opportunitiesTable.renderOpportunitiesTable = () => { renders += 1; };
|
||||
const sr = new Map(), actions = new Map();
|
||||
app.opportunityCrmServiceRequests.getSummary = number => sr.get(number);
|
||||
app.opportunityWorkloadActions.getSummary = number => actions.get(number);
|
||||
const form = new Element("form"), status = new Element("status"), stage = new Element("stage");
|
||||
const controls = app.activityFilters.createControls(form, status, stage);
|
||||
const buttons = kind => controls.children[kind === "sr" ? 0 : 1].children[1].children;
|
||||
const button = (kind, value) => buttons(kind).find(node => node.attributes["data-value"] === value);
|
||||
return { app, sr, actions, form, controls, status, stage, button, renders: () => renders,
|
||||
matches: number => app.activityFilters.matches({ OptyNumber: number }),
|
||||
click: (kind, value) => button(kind, value).listeners.click() };
|
||||
}
|
||||
|
||||
test("activity badge filters precede Status, are accessible and combine without network", () => {
|
||||
const h = setup();
|
||||
assert.equal(h.controls.children[2], h.status);
|
||||
assert.equal(h.controls.children[0].children[0].textContent, "Service Request");
|
||||
assert.equal(h.controls.children[0].children[1].attributes["aria-label"], "Filter by Service Request");
|
||||
assert.equal(h.controls.children[3], h.stage);
|
||||
assert.equal(h.form.attributes["data-activity-filters"], "true");
|
||||
assert.equal(h.button("sr", "matched").textContent, "\u2705 SR");
|
||||
assert.equal(h.button("actions", "missing").textContent, "\u26d4 Action");
|
||||
assert.equal(h.button("actions", "matched").type, "button");
|
||||
h.sr.set("A", { state: "matched" });
|
||||
h.actions.set("A", { state: "missing" });
|
||||
h.sr.set("B", { state: "missing" });
|
||||
h.actions.set("B", { state: "matched" });
|
||||
assert.equal(h.matches("unknown"), true);
|
||||
h.click("sr", "missing");
|
||||
assert.equal(h.matches("A"), true);
|
||||
assert.equal(h.matches("B"), false);
|
||||
assert.equal(h.matches("unknown"), false);
|
||||
h.click("actions", "missing");
|
||||
assert.equal(h.matches("A"), false, "SR and Action use AND");
|
||||
h.actions.set("A", { state: "matched" });
|
||||
assert.equal(h.matches("A"), true);
|
||||
assert.equal(h.renders(), 2);
|
||||
});
|
||||
|
||||
test("SR below four hours is exclusive and regular SR options can replace it", () => {
|
||||
const h = setup();
|
||||
h.sr.set("A", { state: "matched", hasUnderFourHours: true });
|
||||
h.sr.set("B", { state: "matched", hasUnderFourHours: false });
|
||||
h.click("sr", "under4");
|
||||
assert.equal(h.button("sr", "matched").attributes["aria-pressed"], "false");
|
||||
assert.equal(h.button("sr", "missing").attributes["aria-pressed"], "false");
|
||||
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "true");
|
||||
assert.equal(h.matches("A"), true);
|
||||
assert.equal(h.matches("B"), false);
|
||||
h.click("sr", "matched");
|
||||
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "false");
|
||||
assert.equal(h.matches("B"), true);
|
||||
h.click("sr", "matched");
|
||||
assert.equal(h.matches("A"), false, "empty selection matches no records, like Status");
|
||||
h.click("sr", "under4");
|
||||
h.click("sr", "missing");
|
||||
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "false");
|
||||
assert.equal(h.matches("A"), false);
|
||||
});
|
||||
|
||||
test("missing filters exclude unknown, loading and error states; SALES_REP bypasses and hides them", () => {
|
||||
const h = setup();
|
||||
h.click("sr", "matched");
|
||||
h.click("actions", "matched");
|
||||
for (const state of ["idle", "loading", "error", "matched"]) {
|
||||
h.sr.set("A", { state });
|
||||
h.actions.set("A", { state: "missing" });
|
||||
assert.equal(h.matches("A"), false);
|
||||
h.sr.set("A", { state: "missing" });
|
||||
h.actions.set("A", { state });
|
||||
assert.equal(h.matches("A"), false);
|
||||
}
|
||||
h.actions.set("A", { state: "missing" });
|
||||
assert.equal(h.matches("A"), true);
|
||||
h.app.currentUser.allowsCrmActivities = () => false;
|
||||
h.app.activityFilters.render();
|
||||
assert.equal(h.matches("unknown"), true);
|
||||
assert.equal(h.controls.children[0].hidden, true);
|
||||
assert.equal(h.controls.children[1].hidden, true);
|
||||
assert.equal(h.form.attributes["data-activity-filters"], "false");
|
||||
});
|
||||
|
||||
for (const [roles, allowed] of [[ ["SALES_REP"], false ], [ ["SIMPLE_USER", "SALES_REP"], false ], [ ["SOLUTION_ENG_NOTERR"], true ]]) {
|
||||
test(`requestCurrentUser roles ${JSON.stringify(roles)} control actual filter visibility`, async () => {
|
||||
const app = loadApplication("content", { document: { createElement: tag => new Element(tag) } });
|
||||
const calls = [];
|
||||
let finish;
|
||||
app.platformContentMessages.sendRuntimeMessage = message => {
|
||||
calls.push(message.type);
|
||||
return new Promise(resolve => { finish = resolve; });
|
||||
};
|
||||
app.opportunitiesTable.renderOpportunitiesTable = () => {};
|
||||
const form = new Element("form");
|
||||
const controls = app.activityFilters.createControls(form, new Element("status"), new Element("stage"));
|
||||
assert.equal(controls.children[0].hidden, true, "hidden before identity resolves");
|
||||
assert.equal(controls.children[1].hidden, true);
|
||||
const identity = app.currentUser.ensure();
|
||||
finish({ ok: true, user: { roles } });
|
||||
await identity;
|
||||
app.activityFilters.render();
|
||||
assert.equal(controls.children[0].hidden, !allowed);
|
||||
assert.equal(controls.children[1].hidden, !allowed);
|
||||
if (!allowed) {
|
||||
app.opportunityCrmServiceRequests.getSummary = () => assert.fail("must not inspect SR data");
|
||||
app.opportunityWorkloadActions.getSummary = () => assert.fail("must not inspect Actions data");
|
||||
app.activityFilters.toggle("sr", "under4");
|
||||
assert.equal(app.activityFilters.matches({ OptyNumber: "A" }), true);
|
||||
}
|
||||
assert.deepEqual(calls, ["opportunitiesExtension.requestCurrentUser"]);
|
||||
});
|
||||
}
|
||||
|
||||
test("SR filter summary uses actual records: zero, decimals, exact four, missing and multiple hours", async () => {
|
||||
const app = loadApplication("content");
|
||||
app.currentUser.allowsCrmActivities = () => true;
|
||||
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||
app.activityProgress.update = () => {};
|
||||
const examples = [[0, true], [3.99, true], [4, false], [8, false], [null, false], ["", false], [" ", false], ["3.5", true], ["bad", false]];
|
||||
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [
|
||||
...examples.map(([value], index) => ({ OpportunityName_Id_c: { OptyNumber: String(index) }, ReportedHours_c: value })),
|
||||
...[2, 8].map(value => ({ OpportunityName_Id_c: { OptyNumber: "multiple" }, ReportedHours_c: value }))
|
||||
];
|
||||
await app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||
for (const [index, [, expected]] of examples.entries()) {
|
||||
assert.equal(app.opportunityCrmServiceRequests.getSummary(String(index)).hasUnderFourHours, expected);
|
||||
}
|
||||
assert.equal(app.opportunityCrmServiceRequests.getSummary("multiple").reportedHours, 10);
|
||||
assert.equal(app.opportunityCrmServiceRequests.getSummary("multiple").hasUnderFourHours, true);
|
||||
assert.equal(app.opportunityCrmServiceRequests.getSummary("absent").state, "missing");
|
||||
});
|
||||
|
||||
test("Action filter summary shares badge ownership and uncertainty rules", async () => {
|
||||
const app = loadApplication("content", { document: { createElement: tag => new Element(tag) } });
|
||||
app.currentUser.allowsCrmActivities = () => true;
|
||||
app.productsContentJobs.queueProductsTableRender = () => {};
|
||||
app.authSessionState.principal = "me@example.invalid";
|
||||
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
|
||||
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true, actions: [{ owner: "other@example.invalid" }], hasNext: false });
|
||||
const products = { items: [{ RevnId: "123" }] };
|
||||
app.productsContentState.opptyProductsByNumber.set("A", products);
|
||||
await app.opportunityWorkloadActions.loadProducts(products);
|
||||
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "missing");
|
||||
app.authSessionState.principal = "other@example.invalid";
|
||||
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "matched");
|
||||
assert.equal(app.opportunityWorkloadActions.createBadge("A").attributes["data-state"], "matched");
|
||||
app.authSessionState.principal = "";
|
||||
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "error");
|
||||
});
|
||||
|
||||
test("activity filters compose with existing search, status, owner and customer filters", () => {
|
||||
const h = setup();
|
||||
const items = ["A", "B"].map(OptyNumber => ({ OptyNumber, Name: `Test ${OptyNumber}`, StatusCode: "OPEN", ForecastGroup_c: "FORECAST",
|
||||
CustomerAccount: { PartyUniqueName: "Synthetic customer", PartyId: "123" }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner" } }));
|
||||
h.app.opportunitiesTableState.opportunitiesTableState.items = items;
|
||||
h.app.opportunitiesFilters.resetCustomerFilter(items);
|
||||
h.app.opportunitiesFilters.resetOwnerFilter(items);
|
||||
h.sr.set("A", { state: "matched" });
|
||||
h.sr.set("B", { state: "missing" });
|
||||
h.click("sr", "missing");
|
||||
const results = () => Array.from(h.app.opportunitiesFilters.getSortedOpportunities(), item => item.OptyNumber);
|
||||
assert.deepEqual(results(), ["A"]);
|
||||
h.app.opportunitiesFilterState.tableSearch = "Test B";
|
||||
assert.deepEqual(results(), []);
|
||||
h.app.opportunitiesFilterState.tableSearch = "";
|
||||
h.app.opportunitiesFilterState.selectedStatuses.clear();
|
||||
assert.deepEqual(results(), []);
|
||||
});
|
||||
|
||||
test("activity filters have theme contrast, visible keyboard focus and responsive wrapping", () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
|
||||
assert.match(css, /activity-filter-button:focus-visible/);
|
||||
assert.match(css, /data-theme="dark"\] \.opportunities-extension-activity-filter-button/);
|
||||
assert.match(css, /activity-filter\[hidden\] \{ display: none;/);
|
||||
assert.match(css, /badge-filters \{\s*display: flex;[\s\S]*?flex-wrap: wrap;/);
|
||||
});
|
||||
|
||||
test("desktop keeps fields and badge groups on one line, wrapping only below the compact breakpoint", () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
|
||||
assert.match(css, /\.opportunities-extension-badge-filters \{ display: contents; \}/);
|
||||
assert.match(css, /\[data-activity-filters="true"\] \{\s*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\) repeat\(4, max-content\);/);
|
||||
const breakpoint = css.indexOf("@media (max-width: 1350px)");
|
||||
const fullRow = css.indexOf("grid-column: 1 / -1;", css.indexOf(".opportunities-extension-badge-filters { display: contents; }"));
|
||||
assert.ok(breakpoint >= 0 && fullRow > breakpoint, "full-row badge toolbar is limited to smaller viewports");
|
||||
assert.match(css, /activity-filter-controls \{ display: flex; align-items: center; flex-wrap: nowrap;/);
|
||||
});
|
||||
@@ -20,6 +20,19 @@ function fixture() {
|
||||
return { ...dom, app, add, nodes, tab, ids, select };
|
||||
}
|
||||
|
||||
test("request summaries show duration after status, including zero, failure and pending", () => {
|
||||
const h = fixture();
|
||||
h.add("duration", "requestOpportunities", 1);
|
||||
const entry = h.app.debugState.requestLog.at(-1);
|
||||
for (const [durationMs, status, expected] of [[0, "success", "0 ms"], [325, "success", "325 ms"], [1540, "failed", "1.54 s"], [null, "pending", "In progress"], [null, "failed", "N/D"]]) {
|
||||
Object.assign(entry, { durationMs, status });
|
||||
h.app.debugContentView.renderDebugPanel();
|
||||
const meta = h.nodes().find(node => node.className === "opportunities-extension-debug-summary-meta");
|
||||
assert.equal(meta.children.at(-1).textContent, expected);
|
||||
assert.equal(meta.children.at(-2).className, "opportunities-extension-debug-status");
|
||||
}
|
||||
});
|
||||
|
||||
test("large response previews stay valid, formatted, masked and colored in both runtimes", () => {
|
||||
const h = fixture();
|
||||
const background = loadApplication("background");
|
||||
|
||||
@@ -69,10 +69,11 @@ test("ten columns retain their links and put product/customer controls below the
|
||||
assert.equal(findClass(h.cell("customer"), "opportunities-extension-products-count"), undefined);
|
||||
});
|
||||
|
||||
test("desktop filter widths fit Reference content and allocate free space to Customer and Owner", () => {
|
||||
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: max-content minmax\(110px, 1fr\) minmax\(110px, 1fr\) minmax\(145px, 180px\) max-content max-content/);
|
||||
assert.match(css, /@media \(min-width: 1151px\)[\s\S]*?\.opportunities-extension-field select\s*\{\s*width: max-content;/);
|
||||
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\)/);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,83 @@ const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const { loadApplication } = require("./support/application.cjs");
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
for (const first of ["SR", "Actions"]) {
|
||||
test(`Last update waits for every background call when ${first} finishes first`, async () => {
|
||||
const h = setup();
|
||||
const sr = deferred(), actions = deferred();
|
||||
let renders = 0;
|
||||
h.app.productsContentJobs.renderProductsProgress = () => { renders += 1; };
|
||||
h.app.productsContentState.productsLastCachedAt = 100;
|
||||
h.app.opportunityCrmServiceRequests.load = () => sr.promise;
|
||||
h.app.opportunityWorkloadActions.loadProducts = () => actions.promise;
|
||||
const id = h.start();
|
||||
h.progress(id, { state: "running", optyNumber: "A", items: [{ RevnId: "1" }], cachedAt: 200 });
|
||||
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||
h.progress(id, { state: "complete" });
|
||||
await flush();
|
||||
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||
(first === "SR" ? sr : actions).resolve();
|
||||
await flush();
|
||||
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||
(first === "SR" ? actions : sr).resolve();
|
||||
await flush();
|
||||
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
|
||||
assert.equal(renders, 1, "publish the completed cycle once");
|
||||
});
|
||||
}
|
||||
|
||||
test("Last update ignores superseded cycles and settles failures without changing the cache timestamp", async () => {
|
||||
const h = setup();
|
||||
const old = deferred(), current = deferred();
|
||||
h.app.productsContentState.productsLastCachedAt = 100;
|
||||
h.app.opportunityCrmServiceRequests.load = () => old.promise;
|
||||
const first = h.start();
|
||||
h.progress(first, { state: "complete", optyNumber: "A", items: [], cachedAt: 200 });
|
||||
h.app.opportunityCrmServiceRequests.load = () => current.promise;
|
||||
const second = h.start();
|
||||
h.progress(second, { state: "complete", optyNumber: "A", items: [], cachedAt: 300 });
|
||||
old.resolve();
|
||||
await flush();
|
||||
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||
current.reject(new Error("Synthetic failure"));
|
||||
await flush();
|
||||
assert.equal(h.app.productsContentState.productsLastCachedAt, 300);
|
||||
});
|
||||
|
||||
test("Last update waits for cache restoration and ignores a superseded Reference", async () => {
|
||||
const h = setup();
|
||||
const restore = deferred();
|
||||
h.app.productsContentState.productsLastCachedAt = 100;
|
||||
h.app.opportunityCrmServiceRequests.load = () => restore.promise;
|
||||
const id = h.start();
|
||||
h.progress(id, { state: "complete", optyNumber: "A", items: [], cachedAt: 200, fromCache: true });
|
||||
await flush();
|
||||
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||
h.app.opportunitiesQueryState.periodRequestVersion += 1;
|
||||
restore.resolve();
|
||||
await flush();
|
||||
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||
const next = h.start();
|
||||
h.progress(next, { state: "complete", optyNumber: "A", items: [], cachedAt: 200, fromCache: true });
|
||||
await flush();
|
||||
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
|
||||
});
|
||||
|
||||
test("Last update completes for SALES_REP without activity requests", async () => {
|
||||
const h = setup();
|
||||
h.app.opportunityCrmServiceRequests.load = () => undefined;
|
||||
h.app.opportunityWorkloadActions.loadProducts = () => undefined;
|
||||
const id = h.start();
|
||||
h.progress(id, { state: "complete", optyNumber: "A", items: [], cachedAt: 200 });
|
||||
await flush();
|
||||
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
|
||||
});
|
||||
|
||||
function setup() {
|
||||
let listener;
|
||||
|
||||
@@ -72,10 +72,12 @@ test("Refresh all deduplicates clicks, clears memory before reload and preserves
|
||||
app.authSessionState.accessToken = "synthetic";
|
||||
app.preferencesState.opptyProductsCacheHours = 48;
|
||||
app.productsContentState.opptyProductsByNumber.set("A", {});
|
||||
app.productsContentState.productsLastCachedAt = 100;
|
||||
app.consumptionTrendState.consumptionTrendCache.set("A", {});
|
||||
const first = app.refreshAll.refresh("Next Quarter");
|
||||
assert.equal(app.refreshAll.refresh("Next Quarter"), first);
|
||||
assert.equal(app.productsContentState.opptyProductsByNumber.size, 0);
|
||||
assert.equal(app.productsContentState.productsLastCachedAt, 100, "keep previous update visible while refreshing");
|
||||
assert.equal(app.consumptionTrendState.consumptionTrendCache.size, 0);
|
||||
assert.deepEqual(calls, ["activities"]);
|
||||
finish();
|
||||
|
||||
@@ -62,6 +62,44 @@ function contentFixture(send) {
|
||||
return app;
|
||||
}
|
||||
|
||||
test("Actions completion includes queued calls, shared pending records, failures and cache writes", async () => {
|
||||
const pending = [];
|
||||
let finishWrite;
|
||||
const app = contentFixture(() => new Promise(resolve => pending.push(resolve)));
|
||||
app.activityCache.read = async () => null;
|
||||
app.activityCache.write = () => new Promise(resolve => { finishWrite = resolve; });
|
||||
const products = { items: Array.from({ length: 5 }, (_, i) => ({ RevnId: String(i + 1) })) };
|
||||
let complete = false, sharedComplete = false;
|
||||
const batch = app.opportunityWorkloadActions.loadProducts(products, true, { cachedAt: 200 }).then(() => { complete = true; });
|
||||
const shared = app.opportunityWorkloadActions.loadProducts(products).then(() => { sharedComplete = true; });
|
||||
await flush();
|
||||
assert.equal(pending.length, 4);
|
||||
pending.slice(0, 4).forEach(resolve => resolve({ ok: false, error: "Synthetic" }));
|
||||
await flush();
|
||||
assert.equal(pending.length, 5);
|
||||
assert.equal(complete, false);
|
||||
assert.equal(sharedComplete, false);
|
||||
pending[4]({ ok: true, actions: [], hasNext: false });
|
||||
await flush();
|
||||
assert.equal(complete, false, "cache write still pending");
|
||||
finishWrite();
|
||||
await batch;
|
||||
await shared;
|
||||
assert.equal(complete, true);
|
||||
assert.equal(sharedComplete, true);
|
||||
});
|
||||
|
||||
test("Actions reset settles discarded queue entries without waiting for unstarted requests", async () => {
|
||||
const pending = [];
|
||||
const app = contentFixture(() => new Promise(resolve => pending.push(resolve)));
|
||||
const batch = app.opportunityWorkloadActions.loadProducts({ items: Array.from({ length: 8 }, (_, i) => ({ RevnId: String(i) })) });
|
||||
await flush();
|
||||
app.opportunityWorkloadActions.reset(false);
|
||||
pending.forEach(resolve => resolve({ ok: true, actions: [], hasNext: false }));
|
||||
await batch;
|
||||
assert.equal(pending.length, 4);
|
||||
});
|
||||
|
||||
test("actions aggregate all products, share RevnId queries across Reference changes and restart on reopen", async () => {
|
||||
const calls = [];
|
||||
const app = contentFixture(async message => {
|
||||
|
||||
Reference in New Issue
Block a user