Partially implemented the Manage Time functionality
This commit is contained in:
272
tests/manageTime.test.cjs
Normal file
272
tests/manageTime.test.cjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user