mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Add test coverage for transforms.ts stage classification logic
Some checks failed
Test Suite / unit-tests (push) Has been cancelled
Some checks failed
Test Suite / unit-tests (push) Has been cancelled
49 tests across all exported functions: resolveDisplayStage (STAGE_ID_MAP lookups, AFTER_ASSESSMENT sub-classification, POST_DESIGN precedence chain, RA ISSUE overrides), classifyDeals, computeDampMouldRisk, computeFunnelStages, computeProjectProgress (Queries exclusion from percentages), computeOutcomeSlices, and computeLiveTrackerData (__ALL__ synthetic project behaviour).
This commit is contained in:
parent
7841e4a556
commit
1345f36d8d
3 changed files with 620 additions and 0 deletions
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { deriveEffectiveRemovalState, computeRemovalStatusByDeal } from "./removalState";
|
||||
|
||||
describe("deriveEffectiveRemovalState", () => {
|
||||
it("pending removal → pending_removal", () => {
|
||||
expect(deriveEffectiveRemovalState({ type: "removal", status: "pending" })).toBe("pending_removal");
|
||||
});
|
||||
|
||||
it("pending re_addition → pending_re_addition", () => {
|
||||
expect(deriveEffectiveRemovalState({ type: "re_addition", status: "pending" })).toBe("pending_re_addition");
|
||||
});
|
||||
|
||||
it("approved removal → removed", () => {
|
||||
expect(deriveEffectiveRemovalState({ type: "removal", status: "approved" })).toBe("removed");
|
||||
});
|
||||
|
||||
it("declined re_addition → removed (re-addition refused means still out)", () => {
|
||||
expect(deriveEffectiveRemovalState({ type: "re_addition", status: "declined" })).toBe("removed");
|
||||
});
|
||||
|
||||
it("approved re_addition → none (back in, no active removal state)", () => {
|
||||
expect(deriveEffectiveRemovalState({ type: "re_addition", status: "approved" })).toBe("none");
|
||||
});
|
||||
|
||||
it("declined removal → none (removal refused, property stays in)", () => {
|
||||
expect(deriveEffectiveRemovalState({ type: "removal", status: "declined" })).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeRemovalStatusByDeal", () => {
|
||||
it("returns empty map for no rows", () => {
|
||||
expect(computeRemovalStatusByDeal([])).toEqual({});
|
||||
});
|
||||
|
||||
it("maps a single active removal row", () => {
|
||||
const rows = [{ hubspotDealId: "d1", type: "removal", status: "pending" }];
|
||||
expect(computeRemovalStatusByDeal(rows)).toEqual({ d1: "pending_removal" });
|
||||
});
|
||||
|
||||
it("omits deals whose most recent state is none", () => {
|
||||
const rows = [{ hubspotDealId: "d1", type: "removal", status: "declined" }];
|
||||
expect(computeRemovalStatusByDeal(rows)).toEqual({});
|
||||
});
|
||||
|
||||
it("deduplicates by deal — first row wins (most recent, caller provides DESC order)", () => {
|
||||
const rows = [
|
||||
{ hubspotDealId: "d1", type: "removal", status: "approved" }, // most recent
|
||||
{ hubspotDealId: "d1", type: "removal", status: "pending" }, // older, ignored
|
||||
];
|
||||
expect(computeRemovalStatusByDeal(rows)).toEqual({ d1: "removed" });
|
||||
});
|
||||
|
||||
it("handles multiple deals independently", () => {
|
||||
const rows = [
|
||||
{ hubspotDealId: "d1", type: "removal", status: "pending" },
|
||||
{ hubspotDealId: "d2", type: "re_addition", status: "pending" },
|
||||
{ hubspotDealId: "d3", type: "re_addition", status: "approved" },
|
||||
];
|
||||
expect(computeRemovalStatusByDeal(rows)).toEqual({
|
||||
d1: "pending_removal",
|
||||
d2: "pending_re_addition",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import type { EffectiveRemovalState, RemovalStatusByDeal } from "./types";
|
||||
|
||||
export function deriveEffectiveRemovalState(row: {
|
||||
type: string;
|
||||
status: string;
|
||||
}): EffectiveRemovalState {
|
||||
if (row.status === "pending") {
|
||||
return row.type === "re_addition" ? "pending_re_addition" : "pending_removal";
|
||||
}
|
||||
if (row.type === "removal" && row.status === "approved") return "removed";
|
||||
if (row.type === "re_addition" && row.status === "declined") return "removed";
|
||||
return "none";
|
||||
}
|
||||
|
||||
// Rows must be ordered by requestedAt DESC so the first row per deal is the most recent.
|
||||
export function computeRemovalStatusByDeal(
|
||||
rows: { hubspotDealId: string; type: string; status: string }[],
|
||||
): RemovalStatusByDeal {
|
||||
const result: RemovalStatusByDeal = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (seen.has(row.hubspotDealId)) continue;
|
||||
seen.add(row.hubspotDealId);
|
||||
|
||||
const state = deriveEffectiveRemovalState(row);
|
||||
if (state !== "none") result[row.hubspotDealId] = state;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -0,0 +1,525 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveDisplayStage,
|
||||
classifyDeals,
|
||||
computeDampMouldRisk,
|
||||
computeFunnelStages,
|
||||
computeProjectProgress,
|
||||
computeOutcomeSlices,
|
||||
computeLiveTrackerData,
|
||||
} from "./transforms";
|
||||
import type { HubspotDeal, ClassifiedDeal } from "./types";
|
||||
|
||||
function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
|
||||
return {
|
||||
id: "1",
|
||||
dealId: "deal-1",
|
||||
dealname: "Test Property",
|
||||
dealstage: null,
|
||||
companyId: null,
|
||||
projectCode: null,
|
||||
landlordPropertyId: null,
|
||||
uprn: null,
|
||||
outcome: null,
|
||||
outcomeNotes: null,
|
||||
majorConditionIssueDescription: null,
|
||||
majorConditionIssuePhotos: null,
|
||||
majorConditionIssuePhotosS3: null,
|
||||
coordinationStatus: null,
|
||||
designStatus: null,
|
||||
pashubLink: null,
|
||||
sharepointLink: null,
|
||||
dampMouldFlag: null,
|
||||
dampMouldAndRepairComments: null,
|
||||
preSapScore: null,
|
||||
coordinator: null,
|
||||
ioeV1Date: null,
|
||||
ioeV2Date: null,
|
||||
ioeV3Date: null,
|
||||
proposedMeasures: null,
|
||||
approvedPackage: null,
|
||||
designer: null,
|
||||
designDate: null,
|
||||
actualMeasuresInstalled: null,
|
||||
installer: null,
|
||||
installerHandover: null,
|
||||
lodgementStatus: null,
|
||||
measuresLodgementDate: null,
|
||||
fullLodgementDate: null,
|
||||
confirmedSurveyDate: null,
|
||||
confirmedSurveyTime: null,
|
||||
surveyedDate: null,
|
||||
designType: null,
|
||||
eiScore: null,
|
||||
eiScorePotential: null,
|
||||
epcSapScore: null,
|
||||
epcSapScorePotential: null,
|
||||
surveyType: null,
|
||||
measuresForPibiOrdered: null,
|
||||
pibiOrderDate: null,
|
||||
pibiCompletedDate: null,
|
||||
propertyHaltedDate: null,
|
||||
propertyHaltedReason: null,
|
||||
technicalApprovedMeasuresForInstall: null,
|
||||
domnaSurveyType: null,
|
||||
domnaSurveyDate: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeClassified(overrides: Partial<ClassifiedDeal> = {}): ClassifiedDeal {
|
||||
return { ...makeDeal(overrides), displayStage: "Scope & Planning", ...overrides };
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// resolveDisplayStage
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("resolveDisplayStage — direct STAGE_ID_MAP lookups", () => {
|
||||
it("returns Scope & Planning for a backlog stage ID", () => {
|
||||
expect(resolveDisplayStage(makeDeal({ dealstage: "1617223910" }))).toBe("Scope & Planning");
|
||||
});
|
||||
|
||||
it("returns Booking in Progress for a bookings stage ID", () => {
|
||||
expect(resolveDisplayStage(makeDeal({ dealstage: "3589581001" }))).toBe("Booking in Progress");
|
||||
});
|
||||
|
||||
it("returns Assessment in Progress for a survey-in-progress stage ID", () => {
|
||||
expect(resolveDisplayStage(makeDeal({ dealstage: "1617223913" }))).toBe("Assessment in Progress");
|
||||
});
|
||||
|
||||
it("returns Queries for a known queries stage ID", () => {
|
||||
expect(resolveDisplayStage(makeDeal({ dealstage: "2663668937" }))).toBe("Queries");
|
||||
});
|
||||
|
||||
it("falls through to AFTER_ASSESSMENT path when dealstage is null", () => {
|
||||
// null dealstage → AFTER_ASSESSMENT → no coord/design status → Coordination in Progress
|
||||
expect(resolveDisplayStage(makeDeal({ dealstage: null }))).toBe("Coordination in Progress");
|
||||
});
|
||||
|
||||
it("falls through to AFTER_ASSESSMENT path for an unrecognised stage ID", () => {
|
||||
expect(resolveDisplayStage(makeDeal({ dealstage: "9999999999" }))).toBe("Coordination in Progress");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDisplayStage — RA ISSUE override on non-AFTER_ASSESSMENT stages", () => {
|
||||
it("overrides Scope & Planning to Queries when coordinationStatus is RA ISSUE", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({ dealstage: "1617223910", coordinationStatus: "RA ISSUE" }))
|
||||
).toBe("Queries");
|
||||
});
|
||||
|
||||
it("overrides Assessment in Progress to Queries when coordinationStatus is RA ISSUE", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({ dealstage: "1617223913", coordinationStatus: "RA ISSUE" }))
|
||||
).toBe("Queries");
|
||||
});
|
||||
|
||||
it("is case-insensitive for RA ISSUE check", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({ dealstage: "1617223913", coordinationStatus: "ra issue" }))
|
||||
).toBe("Queries");
|
||||
});
|
||||
|
||||
it("does NOT override Booking in Progress with RA ISSUE (only Scope & Planning and Assessment in Progress)", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({ dealstage: "3589581001", coordinationStatus: "RA ISSUE" }))
|
||||
).toBe("Booking in Progress");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDisplayStage — AFTER_ASSESSMENT sub-classification", () => {
|
||||
const AFTER_ASSESSMENT_STAGE = "3948185842";
|
||||
|
||||
it("returns Queries when coordinationStatus is RA ISSUE", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({ dealstage: AFTER_ASSESSMENT_STAGE, coordinationStatus: "RA ISSUE" }))
|
||||
).toBe("Queries");
|
||||
});
|
||||
|
||||
it("returns Coordination in Progress by default (no special status)", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({ dealstage: AFTER_ASSESSMENT_STAGE, coordinationStatus: null, designStatus: null }))
|
||||
).toBe("Coordination in Progress");
|
||||
});
|
||||
|
||||
it("returns Design in Progress when coord is IOE/MTP COMPLETE but design not yet uploaded", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({
|
||||
dealstage: AFTER_ASSESSMENT_STAGE,
|
||||
coordinationStatus: "(V1) IOE/MTP COMPLETE",
|
||||
designStatus: "IN PROGRESS",
|
||||
}))
|
||||
).toBe("Design in Progress");
|
||||
});
|
||||
|
||||
it("returns Design in Progress for V2 IOE/MTP COMPLETE variant", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({
|
||||
dealstage: AFTER_ASSESSMENT_STAGE,
|
||||
coordinationStatus: "(V2) IOE/MTP COMPLETE",
|
||||
designStatus: null,
|
||||
}))
|
||||
).toBe("Design in Progress");
|
||||
});
|
||||
|
||||
it("returns Design in Progress for V3 IOE/MTP COMPLETE variant", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({
|
||||
dealstage: AFTER_ASSESSMENT_STAGE,
|
||||
coordinationStatus: "(V3) IOE/MTP COMPLETE",
|
||||
designStatus: null,
|
||||
}))
|
||||
).toBe("Design in Progress");
|
||||
});
|
||||
|
||||
it("is case-insensitive for coord/design status checks", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makeDeal({
|
||||
dealstage: AFTER_ASSESSMENT_STAGE,
|
||||
coordinationStatus: "(v1) ioe/mtp complete",
|
||||
designStatus: "uploaded",
|
||||
}))
|
||||
).toBe("Installation in Progress"); // POST_DESIGN, no install fields set
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDisplayStage — POST_DESIGN sub-classification (design UPLOADED)", () => {
|
||||
const AFTER_ASSESSMENT_STAGE = "3948185842";
|
||||
|
||||
function makePostDesignDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
|
||||
return makeDeal({
|
||||
dealstage: AFTER_ASSESSMENT_STAGE,
|
||||
coordinationStatus: "(V1) IOE/MTP COMPLETE",
|
||||
designStatus: "UPLOADED",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it("returns Installation in Progress when no install fields are set", () => {
|
||||
expect(resolveDisplayStage(makePostDesignDeal())).toBe("Installation in Progress");
|
||||
});
|
||||
|
||||
it("returns Installation Complete when actualMeasuresInstalled is set", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makePostDesignDeal({ actualMeasuresInstalled: "Insulation" }))
|
||||
).toBe("Installation Complete");
|
||||
});
|
||||
|
||||
it("returns Installation Complete when installerHandover is set", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makePostDesignDeal({ installerHandover: "2024-01-01" }))
|
||||
).toBe("Installation Complete");
|
||||
});
|
||||
|
||||
it("returns At Lodgement when lodgementStatus is set", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makePostDesignDeal({ lodgementStatus: "Submitted" }))
|
||||
).toBe("At Lodgement");
|
||||
});
|
||||
|
||||
it("returns At Post Survey when measuresLodgementDate is set", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makePostDesignDeal({ measuresLodgementDate: new Date() }))
|
||||
).toBe("At Post Survey");
|
||||
});
|
||||
|
||||
it("returns Project Complete when fullLodgementDate is set", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makePostDesignDeal({ fullLodgementDate: new Date() }))
|
||||
).toBe("Project Complete");
|
||||
});
|
||||
|
||||
it("fullLodgementDate takes precedence over measuresLodgementDate and lodgementStatus", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makePostDesignDeal({
|
||||
fullLodgementDate: new Date(),
|
||||
measuresLodgementDate: new Date(),
|
||||
lodgementStatus: "Submitted",
|
||||
}))
|
||||
).toBe("Project Complete");
|
||||
});
|
||||
|
||||
it("measuresLodgementDate takes precedence over lodgementStatus", () => {
|
||||
expect(
|
||||
resolveDisplayStage(makePostDesignDeal({
|
||||
measuresLodgementDate: new Date(),
|
||||
lodgementStatus: "Submitted",
|
||||
}))
|
||||
).toBe("At Post Survey");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// classifyDeals
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("classifyDeals", () => {
|
||||
it("adds displayStage to each deal", () => {
|
||||
const deals = [
|
||||
makeDeal({ dealstage: "1617223910" }),
|
||||
makeDeal({ dealstage: "1617223913" }),
|
||||
];
|
||||
const result = classifyDeals(deals);
|
||||
expect(result[0].displayStage).toBe("Scope & Planning");
|
||||
expect(result[1].displayStage).toBe("Assessment in Progress");
|
||||
});
|
||||
|
||||
it("returns an empty array for no deals", () => {
|
||||
expect(classifyDeals([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves all original deal fields", () => {
|
||||
const deal = makeDeal({ dealId: "abc-123", dealname: "Test" });
|
||||
const [result] = classifyDeals([deal]);
|
||||
expect(result.dealId).toBe("abc-123");
|
||||
expect(result.dealname).toBe("Test");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// computeDampMouldRisk
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("computeDampMouldRisk", () => {
|
||||
it("counts survey flags from majorConditionIssuePhotosS3", () => {
|
||||
const deals = [
|
||||
makeClassified({ majorConditionIssuePhotosS3: "s3://bucket/file.jpg" }),
|
||||
makeClassified({ majorConditionIssuePhotosS3: null }),
|
||||
];
|
||||
const result = computeDampMouldRisk(deals);
|
||||
expect(result.surveyFlagCount).toBe(1);
|
||||
});
|
||||
|
||||
it("counts coordinator flags from dampMouldFlag", () => {
|
||||
const deals = [
|
||||
makeClassified({ dampMouldFlag: "Yes" }),
|
||||
makeClassified({ dampMouldFlag: null }),
|
||||
makeClassified({ dampMouldFlag: "Yes" }),
|
||||
];
|
||||
const result = computeDampMouldRisk(deals);
|
||||
expect(result.coordinatorFlagCount).toBe(2);
|
||||
});
|
||||
|
||||
it("counts deals flagged at both stages independently", () => {
|
||||
const deals = [
|
||||
makeClassified({ majorConditionIssuePhotosS3: "s3://x", dampMouldFlag: "Yes" }),
|
||||
makeClassified({ majorConditionIssuePhotosS3: "s3://y", dampMouldFlag: null }),
|
||||
makeClassified({ majorConditionIssuePhotosS3: null, dampMouldFlag: "Yes" }),
|
||||
];
|
||||
const result = computeDampMouldRisk(deals);
|
||||
expect(result.surveyFlagCount).toBe(2);
|
||||
expect(result.coordinatorFlagCount).toBe(2);
|
||||
expect(result.bothFlaggedCount).toBe(1);
|
||||
});
|
||||
|
||||
it("returns zero counts for empty deals", () => {
|
||||
const result = computeDampMouldRisk([]);
|
||||
expect(result.surveyFlagCount).toBe(0);
|
||||
expect(result.coordinatorFlagCount).toBe(0);
|
||||
expect(result.bothFlaggedCount).toBe(0);
|
||||
expect(result.totalDeals).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// computeFunnelStages
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("computeFunnelStages", () => {
|
||||
it("excludes Queries deals from counts and percentages", () => {
|
||||
const deals = [
|
||||
makeClassified({ displayStage: "Scope & Planning" }),
|
||||
makeClassified({ displayStage: "Queries" }),
|
||||
];
|
||||
const funnel = computeFunnelStages(deals);
|
||||
const scopeEntry = funnel.find((f) => f.stage === "Scope & Planning")!;
|
||||
expect(scopeEntry.currentPct).toBe(100); // 1 out of 1 non-query
|
||||
});
|
||||
|
||||
it("returns 0 percentages when all deals are Queries", () => {
|
||||
const deals = [makeClassified({ displayStage: "Queries" })];
|
||||
const funnel = computeFunnelStages(deals);
|
||||
funnel.forEach((f) => {
|
||||
expect(f.currentPct).toBe(0);
|
||||
expect(f.cumulativePct).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("cumulative count includes all deals at or beyond a given stage", () => {
|
||||
const deals = [
|
||||
makeClassified({ displayStage: "Installation in Progress" }),
|
||||
makeClassified({ displayStage: "Installation Complete" }),
|
||||
makeClassified({ displayStage: "Project Complete" }),
|
||||
];
|
||||
const funnel = computeFunnelStages(deals);
|
||||
const installEntry = funnel.find((f) => f.stage === "Installation in Progress")!;
|
||||
// All 3 deals are at or beyond "Installation in Progress"
|
||||
expect(installEntry.cumulativeCount).toBe(3);
|
||||
expect(installEntry.currentCount).toBe(1);
|
||||
});
|
||||
|
||||
it("returns an entry for every stage in STAGE_ORDER", () => {
|
||||
const funnel = computeFunnelStages([]);
|
||||
expect(funnel.length).toBe(10); // STAGE_ORDER has 10 entries
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// computeProjectProgress
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("computeProjectProgress", () => {
|
||||
it("excludes Queries from nonQueryTotal but includes them in totalDeals", () => {
|
||||
const deals = [
|
||||
makeClassified({ displayStage: "Scope & Planning" }),
|
||||
makeClassified({ displayStage: "Queries" }),
|
||||
];
|
||||
const result = computeProjectProgress(deals);
|
||||
expect(result.totalDeals).toBe(2);
|
||||
expect(result.nonQueryTotal).toBe(1);
|
||||
expect(result.queriesDeals).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("computes completedPercentage as fraction of non-query total", () => {
|
||||
const deals = [
|
||||
makeClassified({ displayStage: "Project Complete" }),
|
||||
makeClassified({ displayStage: "Project Complete" }),
|
||||
makeClassified({ displayStage: "Scope & Planning" }),
|
||||
makeClassified({ displayStage: "Queries" }), // excluded from denominator
|
||||
];
|
||||
const result = computeProjectProgress(deals);
|
||||
expect(result.completedCount).toBe(2);
|
||||
expect(result.completedPercentage).toBeCloseTo(66.67, 1);
|
||||
expect(result.nonQueryTotal).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 0 percentages when there are no non-query deals", () => {
|
||||
const deals = [makeClassified({ displayStage: "Queries" })];
|
||||
const result = computeProjectProgress(deals);
|
||||
expect(result.completedPercentage).toBe(0);
|
||||
result.stageProgress.forEach((s) => expect(s.percentage).toBe(0));
|
||||
});
|
||||
|
||||
it("returns correct stage counts in stageProgress", () => {
|
||||
const deals = [
|
||||
makeClassified({ displayStage: "Scope & Planning" }),
|
||||
makeClassified({ displayStage: "Scope & Planning" }),
|
||||
makeClassified({ displayStage: "Assessment in Progress" }),
|
||||
];
|
||||
const result = computeProjectProgress(deals);
|
||||
const scopeItem = result.stageProgress.find((s) => s.stage === "Scope & Planning")!;
|
||||
const assessItem = result.stageProgress.find((s) => s.stage === "Assessment in Progress")!;
|
||||
expect(scopeItem.count).toBe(2);
|
||||
expect(assessItem.count).toBe(1);
|
||||
});
|
||||
|
||||
it("stageProgress does not include a Queries entry", () => {
|
||||
const result = computeProjectProgress([makeClassified({ displayStage: "Queries" })]);
|
||||
expect(result.stageProgress.find((s) => s.stage === "Queries")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// computeOutcomeSlices
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("computeOutcomeSlices", () => {
|
||||
it("counts only known SURVEYOR_OUTCOMES values", () => {
|
||||
const deals = [
|
||||
makeClassified({ outcome: "Surveyed" }),
|
||||
makeClassified({ outcome: "Surveyed" }),
|
||||
makeClassified({ outcome: "UNKNOWN_VALUE" }),
|
||||
makeClassified({ outcome: null }),
|
||||
];
|
||||
const slices = computeOutcomeSlices(deals);
|
||||
expect(slices).toHaveLength(1);
|
||||
expect(slices[0].name).toBe("Surveyed");
|
||||
expect(slices[0].amount).toBe(2);
|
||||
});
|
||||
|
||||
it("computes percentage as a formatted string with one decimal place", () => {
|
||||
const deals = [
|
||||
makeClassified({ outcome: "Surveyed" }),
|
||||
makeClassified({ outcome: "Surveyed" }),
|
||||
makeClassified({ outcome: "Other" }),
|
||||
makeClassified({ outcome: "Other" }),
|
||||
];
|
||||
const slices = computeOutcomeSlices(deals);
|
||||
const surveyed = slices.find((s) => s.name === "Surveyed")!;
|
||||
expect(surveyed.percentage).toBe("50.0");
|
||||
});
|
||||
|
||||
it("returns empty array when no deals have matching outcomes", () => {
|
||||
expect(computeOutcomeSlices([makeClassified({ outcome: null })])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// computeLiveTrackerData
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("computeLiveTrackerData", () => {
|
||||
it("returns a single project entry and no __ALL__ when all deals share one project code", () => {
|
||||
const deals = [
|
||||
makeDeal({ projectCode: "PROJ-A", dealstage: "1617223910" }),
|
||||
makeDeal({ projectCode: "PROJ-A", dealstage: "1617223913" }),
|
||||
];
|
||||
const result = computeLiveTrackerData(deals);
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0].projectCode).toBe("PROJ-A");
|
||||
});
|
||||
|
||||
it("prepends a synthetic __ALL__ entry when deals span multiple project codes", () => {
|
||||
const deals = [
|
||||
makeDeal({ projectCode: "PROJ-A", dealstage: "1617223910" }),
|
||||
makeDeal({ projectCode: "PROJ-B", dealstage: "1617223913" }),
|
||||
];
|
||||
const result = computeLiveTrackerData(deals);
|
||||
expect(result.projects[0].projectCode).toBe("__ALL__");
|
||||
expect(result.projects).toHaveLength(3); // __ALL__ + PROJ-A + PROJ-B
|
||||
});
|
||||
|
||||
it("__ALL__ progress covers all deals across project codes", () => {
|
||||
const deals = [
|
||||
makeDeal({ projectCode: "PROJ-A", dealstage: "1617223910" }),
|
||||
makeDeal({ projectCode: "PROJ-B", dealstage: "1617223913" }),
|
||||
];
|
||||
const result = computeLiveTrackerData(deals);
|
||||
expect(result.projects[0].progress.totalDeals).toBe(2);
|
||||
});
|
||||
|
||||
it("totalDeals is the count of all classified deals", () => {
|
||||
const deals = [
|
||||
makeDeal({ projectCode: "PROJ-A" }),
|
||||
makeDeal({ projectCode: "PROJ-A" }),
|
||||
makeDeal({ projectCode: "PROJ-A" }),
|
||||
];
|
||||
expect(computeLiveTrackerData(deals).totalDeals).toBe(3);
|
||||
});
|
||||
|
||||
it("filters majorConditionDeals by the major condition stage ID", () => {
|
||||
const deals = [
|
||||
makeDeal({ dealstage: "3061261536" }), // MAJOR_CONDITION_STAGE_ID
|
||||
makeDeal({ dealstage: "1617223910" }),
|
||||
];
|
||||
const result = computeLiveTrackerData(deals);
|
||||
expect(result.majorConditionDeals).toHaveLength(1);
|
||||
expect(result.majorConditionDeals[0].dealstage).toBe("3061261536");
|
||||
});
|
||||
|
||||
it("groups deals without a projectCode under Unknown Project", () => {
|
||||
const deals = [makeDeal({ projectCode: null })];
|
||||
const result = computeLiveTrackerData(deals);
|
||||
expect(result.projects[0].projectCode).toBe("Unknown Project");
|
||||
});
|
||||
|
||||
it("returns empty projects and zero counts for no input deals", () => {
|
||||
const result = computeLiveTrackerData([]);
|
||||
expect(result.projects).toHaveLength(0);
|
||||
expect(result.totalDeals).toBe(0);
|
||||
expect(result.majorConditionDeals).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue