Partially implemented the Manage Time functionality

This commit is contained in:
2026-10-16 19:50:39 -03:00
parent 2ae057cc90
commit d392e5ddd7
28 changed files with 2528 additions and 64 deletions

View File

@@ -37,7 +37,7 @@ test("every source belongs to an explicit runtime composition, with no duplicate
}
}
}
assert.deepEqual(sources.content.filter(file => sources.background.includes(file)), ["src/shared/messages.js", "src/shared/debug-body.js"]);
assert.deepEqual(sources.content.filter(file => sources.background.includes(file)), ["src/shared/time-dates.js", "src/shared/messages.js", "src/shared/debug-body.js"]);
});
test("Redwood cascade is byte-identical to the characterized original", () => {

View File

@@ -292,6 +292,7 @@ test("full-page debug CSS defines responsive bounds and readable semantic colors
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", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"]
.concat("src/features/manage-time/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

@@ -30,6 +30,23 @@
onMessage: { addListener: listener => listeners.push(listener) },
async sendMessage(message) {
const operation = message.type.split(".").at(-1);
if (operation === "requestLookupTaskType") return { ok: true, items: [{ lookUpCode_c: "SCTA_SERVICE_CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }, { lookUpCode_c: "SCTA_SERVICE_STANDARD", lookUpMeaning_c: "Standard", tags_c: "SERVICE" }, { lookUpCode_c: "SCTA_NON_SERVICE_TRAINING", lookUpMeaning_c: "Training", tags_c: "NON_SERVICE" }], debugRequests: [] };
if (operation === "requestServiceRequestExpand") return { ok: true, items: [...message.srNumbers, "non-service-sr"].map(SrNumber => ({ SrNumber, SRActivities_c: "Account Plan;Customer Research, Assessment and Recommendation;Success Plan" })), debugRequests: [] };
if (operation === "requestResourceUser") return { ok: true, resource: { resourcePartyId: 123 }, debugRequests: [] };
if (operation === "requestActiveServiceRequests") return { ok: true, items: [
{ srNumber: "SR0001", srTitle: "Opportunity Pursuit - Example Customer" },
{ srNumber: "SR0002", srTitle: "Consumption Services - Example Customer" }
], debugRequests: [] };
if (operation === "requestWeekDetails") {
const selected = new Date(`${message.dateReference}T00:00:00Z`);
const start = new Date(selected); start.setUTCDate(start.getUTCDate() - (start.getUTCDay() + 1) % 7);
const end = new Date(start); end.setUTCDate(end.getUTCDate() + 6);
const apiDate = value => `${String(value.getUTCDate()).padStart(2, "0")}-${value.toLocaleDateString("en-US", { month: "short", timeZone: "UTC" }).toUpperCase()}-${value.getUTCFullYear()}`;
return { ok: true, items: Array.from({ length: 7 }, (_, index) => {
const day = new Date(start); day.setUTCDate(day.getUTCDate() + index);
return { Id: index + 1, WeekId: 419, Date: apiDate(day), StartDate: apiDate(start), EndDate: apiDate(end) };
}), debugRequests: [] };
}
if (operation === "requestPostInternalServiceRequest") return { ok: true, result: { SrNumber: "ISR123" }, debugRequests: [] };
if (operation === "requestCurrentUser") return { ok: true, user: { id: "example@example.invalid", name: "Example Owner", roles: internalSrPreview ? [] : ["SALES_REP"] }, debugRequests: [] };
if (["prepareSrSession", "requestInternalServiceRequest", "requestWlActions"].includes(operation)) return { ok: true, items: [], actions: [], debugRequests: [] };

View File

@@ -0,0 +1,71 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const vm = require("node:vm");
const { test } = require("node:test");
const { loadBackground } = require("./support/characterization.cjs");
function setup() {
let now = 100000, hours = 24, user = "a@example.invalid";
const factory = new vm.Script(fs.readFileSync("src/features/preferences/request-cache.js", "utf8") + "\ncreateFormRequestCache;").runInNewContext({ Date: { now: () => now } });
const cache = factory({ getHours: () => hours, getUserId: () => user });
return { cache, advance: value => { now += value; }, hours: value => { hours = value; }, user: value => { user = value; } };
}
const message = (name, fields = {}) => ({ type: `opportunitiesExtension.${name}`, ...fields });
test("all four form query families reuse successes with parameter-specific keys and live duration", async () => {
const h = setup(); let calls = 0;
const fetch = async request => { calls++; assert.equal(request.cacheMaxAgeMs, 24 * 3600000); return { ok: true, items: [{ name: "Original" }], debugRequests: [{ name: "network" }] }; };
for (const name of ["requestWeekDetails", "requestActiveServiceRequests", "requestServiceRequestExpand", "requestLookupTaskType", "requestActionTeamMembers", "requestActionFindLookup", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestLookupTypePlatform", "requestInternalSrServices"]) {
const msg = message(name, { id: 1 });
const result = await h.cache.send(msg, fetch); result.items[0].name = "Edited";
const cached = await h.cache.send(msg, fetch);
assert.equal(cached.fromCache, true); assert.equal(cached.items[0].name, "Original"); assert.equal(cached.debugRequests.length, 0);
await h.cache.send(message(name, { id: 2 }), fetch);
}
assert.equal(calls, 24);
h.advance(2 * 3600000); h.hours(1);
await h.cache.send(message("requestWeekDetails", { id: 1 }), async request => { calls++; assert.equal(request.cacheMaxAgeMs, 3600000); return { ok: true }; });
assert.equal(calls, 25);
});
test("failed results are never cached and identity changes discard previous responses", async () => {
const h = setup(); let calls = 0;
const send = async () => { calls++; return { ok: calls > 1 }; };
const msg = message("requestLookupTaskType");
await h.cache.send(msg, send); await h.cache.send(msg, send); await h.cache.send(msg, send); assert.equal(calls, 2);
h.user("b@example.invalid"); await h.cache.send(msg, send); assert.equal(calls, 3);
h.user(""); await h.cache.send(msg, send); await h.cache.send(msg, send); assert.equal(calls, 5);
});
test("writes and token requests always execute; successful saves and Refresh all invalidate queries", async () => {
for (const name of ["requestMgmtAction", "requestPostServiceRequest", "requestPostInternalServiceRequest", "invalidateProductsCache"]) {
const h = setup(); let queries = 0, writes = 0;
const query = message("requestActionFindLookup");
const queryTransport = async () => { queries++; return { ok: true }; };
await h.cache.send(query, queryTransport);
for (let i = 0; i < 2; i++) await h.cache.send(message(name, { phase: "confirm" }), async () => { writes++; return { ok: true }; });
await h.cache.send(query, queryTransport);
assert.equal(writes, 2); assert.equal(queries, 2);
}
const h = setup(); let calls = 0;
for (let i = 0; i < 2; i++) await h.cache.send(message("requestSrTokenCatalog"), async () => { calls++; return { ok: true }; });
assert.equal(calls, 2);
});
test("concurrent queries share work but old responses cannot repopulate an invalidated cache", async () => {
const h = setup(); let resolve, calls = 0;
const msg = message("requestLookupTypePlatform");
const transport = () => { calls++; return new Promise(done => { resolve = done; }); };
const first = h.cache.send(msg, transport), second = h.cache.send(msg, transport);
assert.equal(calls, 1); h.cache.invalidate(); resolve({ ok: true, items: [] }); await Promise.all([first, second]);
await h.cache.send(msg, async () => { calls++; return { ok: true }; }); assert.equal(calls, 2);
});
test("resource user respects duration and Refresh all invalidation", async () => {
let calls = 0;
const app = loadBackground({ fetch: async () => { calls++; return new Response(JSON.stringify({ resourcePartyId: 123 })); } });
const msg = message("requestResourceUser", { emailAddress: "a@example.invalid", cacheMaxAgeMs: 3600000 });
await app.dispatch(msg); await app.dispatch(msg); assert.equal(calls, 1);
app.advance(3600000); await app.dispatch(msg); assert.equal(calls, 2);
await app.dispatch(message("invalidateProductsCache")); await app.dispatch(msg); assert.equal(calls, 3);
});
test("Preferences displays generic cache duration while preserving the saved preference key", () => {
const source = fs.readFileSync("src/features/preferences/view.js", "utf8");
assert.match(source, /cacheLabel.textContent = "Cache duration"/);
assert.doesNotMatch(source, /Product cache duration/);
assert.match(source, /cacheSelect.name = "opptyProductsCacheHours"/);
});

272
tests/manageTime.test.cjs Normal file
View File

@@ -0,0 +1,272 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const vm = require("node:vm");
const { loadBackground } = require("./support/characterization.cjs");
const factory = (file, name) => new vm.Script(fs.readFileSync(file, "utf8") + `\n${name};`).runInNewContext();
const dates = factory("src/shared/time-dates.js", "createTimeDates")();
const week = () => Array.from({ length: 7 }, (_, index) => ({ Id: 2927 + index, WeekId: 419, Date: `${10 + index}-OCT-2026`, EndDate: "16-OCT-2026" }));
const flush = () => new Promise(resolve => setImmediate(resolve));
test("time dates use English API dates and calendar-day arithmetic across month/year boundaries", () => {
assert.equal(dates.toApi("2026-10-16"), "16-OCT-2026");
assert.equal(dates.shift("2026-12-28", 7), "2027-01-04");
assert.equal(dates.shift("2026-03-03", -7), "2026-02-24");
assert.equal(dates.fromApi("10-OCT-2026").getUTCDay(), 6);
assert.equal(dates.fromApi("31-FEB-2026"), null);
assert.throws(() => dates.toApi("2026-02-30"));
});
for (const family of ["chrome", "firefox"]) test(`${family}: week details use Session, include cookies and preserve debug response`, async () => {
const calls = [];
const app = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options });
return new Response(JSON.stringify(week()), { headers: { "content-type": "application/json" } });
} });
const result = await app.dispatch({ type: "opportunitiesExtension.requestWeekDetails", dateReference: "2026-10-16" });
assert.equal(result.ok, true); assert.equal(result.items.length, 7);
assert.match(calls[0].url, /oalset_timeentrymobile\/live;profile=PROD\/.*weekDetails\?dateValue=16-OCT-2026$/);
assert.equal(calls[0].options.headers.Authorization, "Session");
assert.equal(calls[0].options.credentials, "include"); assert.equal(calls[0].options.method, "GET");
assert.equal(result.debugRequests[0].name, "requestWeekDetails");
assert.match(result.debugRequests[0].responseBody, /10-OCT-2026/);
assert.equal((await app.dispatch({ type: "opportunitiesExtension.requestWeekDetails", dateReference: "invalid" })).ok, false);
assert.equal(calls.length, 1);
});
test("week errors return status and debug logs without masking failure", async () => {
const app = loadBackground({ fetch: async () => new Response("Unauthorized", { status: 401 }) });
const result = await app.dispatch({ type: "opportunitiesExtension.requestWeekDetails", dateReference: "2026-10-16" });
assert.equal(result.ok, false); assert.equal(result.httpStatus, 401); assert.equal(result.debugRequests.length, 1);
});
function setup(requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => []) {
let document;
class Element {
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; }
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text + this.children.map(child => child.textContent).join(""); }
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
replaceChildren(...children) { this.children = []; this.append(...children); }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, fn) { this.listeners[key] = fn; }
focus() { document.activeElement = this; }
remove() { this.parent.children = this.parent.children.filter(child => child !== this); this.isConnected = false; }
click() { if (!this.disabled) this.listeners.click?.(); }
}
const host = new Element("host");
document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag), getElementById: () => host };
const calls = [];
const searchableSelect = factory("src/features/opportunities/searchable-select.js", "createSearchableSelect")({ environment: { document } });
const app = factory("src/features/manage-time/view.js", "createManageTime")({ MODAL_ID: "host", environment: { document }, searchableSelect, dates, requestServices, requestExpand, requestTaskTypes,
requestWeek: date => new Promise((resolve, reject) => calls.push({ date, resolve, reject })) });
app.setReady(true);
const walk = el => [el, ...el.children.flatMap(walk)];
const nodes = () => walk(host);
const find = label => nodes().find(el => el.attributes["aria-label"] === label);
return { app, calls, nodes, find, host };
}
test("Service Request tooltips use expanded fields in order and keep description separate", async () => {
const keys = ["SrNumber", "AccountPartyUniqueName", "OptyName_c", "OptyNumber_c", "OptyOwner_c", "OptyStatus_c", "ServiceNew_c", "ReportedByPartyUniqueName", "CountryText_c", "StatusCdMeaning", "AssigneePersonName", "ProblemDescription"];
const record = Object.fromEntries(keys.map(key => [key, key === "SrNumber" ? "SR001" : `Value ${key}`]));
record.ProblemDescription = "<b>Plain text</b>\nSecond line";
const h = setup(async () => [{ srNumber: "SR001", srTitle: "Example" }], async () => [record]);
h.app.open(); h.calls[0].resolve(week()); await flush();
const input = h.nodes().find(el => el.attributes.role === "combobox" && el.attributes["aria-label"] === "Service Request, row 1");
input.click();
const list = input.parent.children[1], tooltip = input.parent.children[2];
list.children[0].listeners.mouseenter();
assert.equal(tooltip.hidden, false);
const fields = tooltip.children[0].children;
assert.deepEqual(fields.map(field => field.children[0].textContent), ["SR number", "Account", "Opportunity", "Opty number", "Opty owner", "Opty status", "Service", "Requested by", "Country", "Status", "Service lead", "Description"]);
assert.deepEqual(fields.map(field => field.children[1].textContent), keys.map(key => record[key]));
assert.equal(fields[11].className, "oe-time-sr-description");
assert.equal(fields[11].children[1].textContent, record.ProblemDescription);
assert.equal(fields[11].children[1].children.length, 0);
input.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() {} });
assert.equal(tooltip.hidden, true);
assert.equal(h.nodes().filter(el => el.attributes.role === "tooltip").length, 3);
});
test("Manage Time autocompletes search and select SR, dependent Activity and filtered Task Type", async () => {
const h = setup(async () => [{ srNumber: "SR001", srTitle: "Customer migration" }],
async () => [{ SrNumber: "SR001", SRActivities_c: "Account Plan;Technical Support" }],
async () => [{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Configuration", tags_c: "SERVICE" },
{ lookUpCode_c: "TRAIN", lookUpMeaning_c: "Training", tags_c: "NON_SERVICE" }]);
h.app.open(); h.calls[0].resolve(week()); await flush();
const combo = label => h.nodes().find(el => el.attributes.role === "combobox" && el.attributes["aria-label"] === label);
function choose(label, query) {
const input = combo(label); input.focus(); input.value = query; input.listeners.input();
const list = input.parent.children[1];
assert.equal(list.hidden, false);
assert.equal(list.children.length, 1);
input.listeners.keydown({ key: "Enter", preventDefault() {} });
}
assert.equal(h.find("Service Request, row 1").hidden, true);
assert.equal(combo("Activity, row 1").disabled, true);
choose("Service Request, row 1", "sr001");
assert.equal(combo("Service Request, row 1").value, "Customer migration (SR001)");
assert.equal(h.find("Service Request, row 1").value, "SR001");
assert.equal(combo("Activity, row 1").disabled, false);
choose("Activity, row 1", "technical");
choose("Task Type, row 1", "config");
assert.equal(h.find("Task Type, row 1").value, "CONFIG");
h.find("Copy row 1").click();
assert.equal(combo("Activity, row 4").value, "Technical Support");
h.find("Add (Non service)").click();
choose("Task Type, row 5", "train");
const ids = h.nodes().filter(el => el.attributes.role === "combobox").map(el => el.id);
assert.equal(new Set(ids).size, ids.length);
});
for (const family of ["chrome", "firefox"]) test(`${family}: Task Type lookup uses the exact endpoint, Session and logs its response`, async () => {
const app = loadBackground({ family, fetch: async (url, options) => {
assert.match(url, /oalset_timeentrymobile\/live;profile=PROD\/.*\/crm\/lookups\?lookupType=SCTA_TASK_TYPE&type=CUSTOM$/);
assert.equal(options.method, "GET"); assert.equal(options.credentials, "include");
assert.equal(options.headers.Authorization, "Session");
return new Response(JSON.stringify({ items: [{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config" }] }));
} });
const result = await app.dispatch({ type: "opportunitiesExtension.requestLookupTaskType" });
assert.equal(result.ok, true); assert.equal(result.items[0].lookUpCode_c, "CONFIG");
assert.equal(result.debugRequests[0].name, "requestLookupTaskType");
});
test("Task Types load once per opening and retain selected codes when copying or navigating weeks", async () => {
let count = 0;
const h = setup(undefined, undefined, async () => { count++; return [{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }]; });
h.app.open(); h.calls[0].resolve(week()); await flush();
const select = h.find("Task Type, row 1");
assert.equal(select.disabled, false); assert.equal(select.children[1].value, "CONFIG");
assert.equal(select.children[1].textContent, "Config"); select.value = "CONFIG"; select.listeners.change();
h.find("Copy row 1").click(); assert.equal(h.find("Task Type, row 4").value, "CONFIG");
h.find("Next week").click(); h.calls[1].resolve(week()); await flush();
assert.equal(h.find("Task Type, row 1").value, "CONFIG"); assert.equal(count, 1);
});
test("Task Types match the exact row tag, including new and copied non-service rows", async () => {
const h = setup(undefined, undefined, async () => [
{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" },
{ lookUpCode_c: "TRAINING", lookUpMeaning_c: "Training", tags_c: "NON_SERVICE" },
{ lookUpCode_c: "OTHER", lookUpMeaning_c: "Other", tags_c: "OTHER" },
{ lookUpCode_c: "UNTAGGED", lookUpMeaning_c: "Untagged" }
]);
h.app.open(); h.calls[0].resolve(week()); await flush();
assert.deepEqual(h.find("Task Type, row 1").children.map(item => item.value), ["", "CONFIG"]);
h.find("Add (Non service)").click();
const select = h.find("Task Type, row 4");
assert.deepEqual(select.children.map(item => item.value), ["", "TRAINING"]);
select.value = "TRAINING"; select.listeners.change(); h.find("Copy row 4").click();
assert.equal(h.find("Task Type, row 5").value, "TRAINING");
assert.deepEqual(h.find("Task Type, row 5").children.map(item => item.value), ["", "TRAINING"]);
});
test("Task Type stays disabled when no options match the row tag", async () => {
const h = setup(undefined, undefined, async () => [{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }]);
h.app.open(); h.calls[0].resolve(week()); await flush(); h.find("Add (Non service)").click();
assert.equal(h.find("Task Type, row 4").disabled, true);
assert.equal(h.find("Task Type, row 4").children.length, 1);
});
test("Task Type failure can be retried and late responses cannot alter a closed modal", async () => {
let resolve;
let count = 0;
const h = setup(undefined, undefined, () => {
if (++count === 1) return Promise.reject(new Error("Lookup unavailable"));
return new Promise(done => { resolve = done; });
});
h.app.open(); h.calls[0].resolve(week()); await flush();
assert.equal(h.find("Task Type, row 1").disabled, true);
assert.equal(h.find("Retry Task Types").hidden, false);
h.find("Retry Task Types").click(); h.app.close(); resolve([]); await flush();
assert.equal(h.host.children.length, 0);
});
test("modal loads today, navigates seven days, ignores stale results and preserves drafts within the modal", async () => {
const h = setup(); h.app.open();
assert.equal(h.calls[0].date, dates.today());
const date = h.find("Reference date"); date.value = "2026-10-16"; date.listeners.change();
h.calls[1].resolve(week()); await flush();
assert.equal(h.nodes().filter(el => el.tag === "th").length, 13);
assert.equal(h.nodes().filter(el => el.type === "number").length, 21);
assert.equal(h.find("Save").disabled, true);
const hours = h.find("Hours 10-OCT-2026, row 1"); hours.value = "2"; hours.valueAsNumber = 2; hours.listeners.input();
assert.equal(hours.step, "1");
assert.ok(h.nodes().some(el => el.tag === "strong" && el.textContent === "2 hrs"));
h.find("Copy row 1").click(); assert.equal(h.nodes().filter(el => el.type === "number").length, 28);
h.find("Next week").click(); assert.equal(h.calls[2].date, "2026-10-23");
h.find("Previous week").click(); assert.equal(h.calls[3].date, "2026-10-16");
h.calls[3].resolve(week()); await flush();
assert.equal(h.find("Hours 10-OCT-2026, row 1").value, "2");
h.calls[2].reject(new Error("Stale error")); h.calls[0].resolve([]); await flush();
assert.ok(!h.host.textContent.includes("Stale error"));
h.app.close(); assert.equal(h.host.children.length, 0);
});
test("Manage Time is disabled until session ready, and activities correlate with selected SR", async () => {
const h = setup(async () => [{ srNumber: "SR1" }, { srNumber: "SR2" }], async () => [
{ SrNumber: "SR1", SRActivities_c: "Design;Sizing, Quoting or Configuration;Design" },
{ SrNumber: "SR2", SRActivities_c: "Review, Workshop" },
{ SrNumber: "non-service-sr", SRActivities_c: "Training;Meeting" }
]);
h.app.setReady(false); const trigger = h.app.createButton();
assert.equal(trigger.disabled, true); h.app.open(); assert.equal(h.calls.length, 0);
h.app.setReady(true); trigger.click(); h.calls[0].resolve(week()); await flush();
let select = h.find("Service Request, row 1"); select.value = "SR1"; select.listeners.change();
let activity = h.find("Activity, row 1");
assert.deepEqual(activity.children.map(el => el.textContent), ["Activity", "Design", "Sizing, Quoting or Configuration"]);
activity.value = "Design"; activity.listeners.change();
select = h.find("Service Request, row 1"); select.value = "SR2"; select.listeners.change();
activity = h.find("Activity, row 1"); assert.equal(activity.value, "");
assert.deepEqual(activity.children.map(el => el.textContent), ["Activity", "Review", "Workshop"]);
h.find("Add (Non service)").click(); assert.equal(h.find("Activity, row 4").children[1].value, "Training");
});
for (const count of [0, 49, 50, 51, 100, 101]) test(`expansion batches ${count} unique SRs and appends non-service exactly once`, async () => {
const queries = [];
const app = loadBackground({ fetch: async (url, options) => {
queries.push(new URL(url).searchParams.get("q"));
assert.equal(options.headers["vb-proxy-header-rest-framework-version"], "3");
assert.equal(options.headers.Authorization, "Session");
assert.equal(options.credentials, "include");
return new Response(JSON.stringify({ items: [] }));
} });
const numbers = Array.from({ length: count }, (_, i) => `SR${i + 1}`);
const result = await app.dispatch({ type: "opportunitiesExtension.requestServiceRequestExpand", srNumbers: [...numbers, ...numbers] });
assert.equal(result.ok, true);
assert.equal(queries.length, Math.floor(count / 50) + 1);
const batches = queries.map(query => [...query.matchAll(/'([^']+)'/g)].map(match => match[1]));
assert.deepEqual(batches.flat(), [...numbers, "non-service-sr"]);
assert.ok(batches.every(batch => batch.length <= 50));
});
test("modal supports retry and ignores completion after close", async () => {
const h = setup(); h.app.open(); h.calls[0].reject(new Error("Session expired")); await flush();
assert.equal(h.find("Retry").hidden, false); assert.ok(h.host.textContent.includes("Session expired"));
h.find("Retry").click(); h.app.close(); h.calls[1].resolve(week()); await flush();
assert.equal(h.host.children.length, 0);
});
test("active SRs load once per modal and notes preserve text, character count and collapse state", async () => {
const calls = [];
const h = setup(async weekId => { calls.push(weekId); return [{ srNumber: "SR1", srTitle: "Customer service" }]; });
h.app.open(); h.calls[0].resolve(week()); await flush();
const select = h.find("Service Request, row 1");
assert.equal(select.disabled, false); assert.equal(select.children[1].value, "SR1");
assert.equal(select.children[1].textContent, "Customer service (SR1)");
select.value = "SR1"; select.listeners.change();
h.find("Notes, row 1").click();
const notes = h.nodes().find(el => el.tag === "textarea");
assert.equal(notes.maxLength, 2000); notes.value = "Hello"; notes.listeners.input();
assert.ok(h.host.textContent.includes("1995 characters left"));
h.find("Collapse notes, row 1").click();
assert.equal(h.find("Notes, row 1").attributes["aria-expanded"], "false");
h.find("Copy row 1").click(); assert.equal(h.find("Service Request, row 4").value, "SR1");
h.find("Next week").click(); h.calls[1].resolve(week()); await flush();
assert.deepEqual(calls, [419]);
h.app.close(); h.app.open(); h.calls[2].resolve(week()); await flush();
assert.deepEqual(calls, [419, 419]);
});
for (const family of ["chrome", "firefox"]) test(`${family}: resource response is cached across concurrent calls and worker restarts`, async () => {
const calls = [], sessionStorage = {};
const fetch = async (url, options) => {
calls.push({ url, options });
return new Response(JSON.stringify(url.includes("resourceUsers?") ? { resourcePartyId: 123, partyName: "Test User" } : [{ srNumber: "SR1", srTitle: "Test" }]), { headers: { "content-type": "application/json" } });
};
const message = { type: "opportunitiesExtension.requestResourceUser", emailAddress: "test@example.invalid" };
const app = loadBackground({ family, fetch, sessionStorage });
const results = await Promise.all([app.dispatch(message), app.dispatch(message)]);
assert.equal(results[0].resource.resourcePartyId, 123); assert.equal(calls.length, 1);
const restarted = loadBackground({ family, fetch, sessionStorage });
assert.equal((await restarted.dispatch(message)).resource.partyName, "Test User"); assert.equal(calls.length, 1);
const active = await restarted.dispatch({ ...message, type: "opportunitiesExtension.requestActiveServiceRequests", weekId: 419 });
assert.equal(active.ok, true); assert.equal(active.items[0].srNumber, "SR1");
assert.equal(calls.length, 2); assert.match(calls[1].url, /activeServiceRequests\?resourceId=123&weekId=419$/);
assert.equal(calls[1].options.headers.Authorization, "Session");
await restarted.dispatch({ ...message, emailAddress: "other@example.invalid" }); assert.equal(calls.length, 3);
});

View File

@@ -254,6 +254,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", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.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", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css", "src/features/manage-time/styles.css"].map(read).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
});

View File

@@ -6,12 +6,20 @@ test("preparation runs once, shares pending work and reports loading and complet
const app = loadApplication("content");
const progress = [];
let requests = 0;
let resourceRequests = 0;
let resolveUser, resolveSession;
app.currentUser.ensure = () => new Promise(resolve => { resolveUser = resolve; });
app.currentUser.allowsCrmActivities = () => true;
app.currentUser.getUserId = () => "test@example.invalid";
app.activityProgress.update = (...args) => progress.push(args);
app.debugContentLog.appendBackgroundRequestLog = () => {};
app.platformContentMessages.sendRuntimeMessage = message => {
if (message.type === "opportunitiesExtension.requestResourceUser") {
resourceRequests += 1;
assert.equal(message.emailAddress, "test@example.invalid");
assert.deepEqual(progress.at(-1), ["session", "complete"]);
return Promise.resolve({ ok: true, resource: { resourcePartyId: 123 } });
}
assert.equal(message.type, "opportunitiesExtension.prepareSrSession");
requests += 1;
return new Promise(resolve => { resolveSession = resolve; });
@@ -26,6 +34,7 @@ test("preparation runs once, shares pending work and reports loading and complet
await first;
await app.srSessionPreparation.ensure();
assert.equal(requests, 1);
assert.equal(resourceRequests, 1);
assert.deepEqual(progress.at(-1), ["session", "complete"]);
});