From 475aa965b465a6076df999b238ab18bc90391208 Mon Sep 17 00:00:00 2001 From: Robert Bond Date: Wed, 17 Jun 2026 16:06:09 +0000 Subject: [PATCH] Merged PR 2395: helpers for dashboard domain layer Related work items: #23754 --- components/myportal/topthree.js | 20 +- components/myportal/viewall.js | 41 +- components/search/searchresults.js | 19 +- lib/domain/dashboard-policy/README.md | 151 +++++++ lib/domain/dashboard-policy/index.js | 1 + .../splitWatchedCasesBySubmissionState.js | 25 ++ pages/myportal/index.js | 19 +- ...oard-searchresults-classification.test.cjs | 293 +++++++++++++ ...dashboard-topthree-classification.test.cjs | 388 ++++++++++++++++++ .../dashboard-viewall-classification.test.cjs | 295 +++++++++++++ ...board-watched-case-classification.test.cjs | 200 +++++++++ 11 files changed, 1375 insertions(+), 77 deletions(-) create mode 100644 lib/domain/dashboard-policy/README.md create mode 100644 lib/domain/dashboard-policy/index.js create mode 100644 lib/domain/dashboard-policy/splitWatchedCasesBySubmissionState.js create mode 100644 tests/phase22/dashboard-searchresults-classification.test.cjs create mode 100644 tests/phase22/dashboard-topthree-classification.test.cjs create mode 100644 tests/phase22/dashboard-viewall-classification.test.cjs create mode 100644 tests/phase22/dashboard-watched-case-classification.test.cjs diff --git a/components/myportal/topthree.js b/components/myportal/topthree.js index 112a3821..91c0a10c 100644 --- a/components/myportal/topthree.js +++ b/components/myportal/topthree.js @@ -12,6 +12,7 @@ import { getPortalModuleDetailsProxy, getWatchedCasesProxy } from "../../actions/services/portalService"; +import { splitWatchedCasesBySubmissionState } from "../../lib/domain/dashboard-policy"; import { getFormCollectionByID, getDetailsProxy } from "../../components/utils"; import transLookup from "../../data/lookuptranslations.json"; import { @@ -122,21 +123,10 @@ const TopThree = (props) => { getWatchedCasesProxy( props.accountDetails.loggedinUserId ).then((data) => { - let showWatchedCases = (submittedArr) => { - const required = submittedArr.value.filter((el) => { - return ( - el.pinswg_representationsubmitted == null - ); - }); - - let newObj = {}; - return Object.assign(newObj, { - "@odata.count": required.length, - "value": required - }); - }; - - let filteredWatchedCases = showWatchedCases(data); + let filteredWatchedCases = + splitWatchedCasesBySubmissionState( + data.value + ).watchedCases; (setWatchedCases(filteredWatchedCases), getDetailsProxy( diff --git a/components/myportal/viewall.js b/components/myportal/viewall.js index 4e0e8ddb..d758ba81 100644 --- a/components/myportal/viewall.js +++ b/components/myportal/viewall.js @@ -18,6 +18,7 @@ import { getWatchedCases, getWatchedCasesProxy } from "../../actions/services/portalService"; +import { splitWatchedCasesBySubmissionState } from "../../lib/domain/dashboard-policy"; import { consoleLogger } from "../../actions/core/logger"; import { getDetailsProxy, getFormCollectionByID } from "../../components/utils"; import transLookup from "../../data/lookuptranslations.json"; @@ -929,21 +930,10 @@ const ViewAllResults = (props) => { .then((data) => data) .then(() => { getWatchedCasesProxy(cookies.pinsUser).then((data) => { - let showWatchedCases = (submittedArr) => { - const required = submittedArr.value.filter((el) => { - return ( - el.pinswg_representationsubmitted == null - ); - }); - - let newObj = {}; - return Object.assign(newObj, { - "@odata.count": required.length, - "value": required - }); - }; - - let filteredWatchedCases = showWatchedCases(data); + let filteredWatchedCases = + splitWatchedCasesBySubmissionState( + data.value + ).watchedCases; (setWatchedCases(filteredWatchedCases), getDetailsProxy( filteredWatchedCases, @@ -995,24 +985,9 @@ const ViewAllResults = (props) => { .then(() => { getWatchedCasesProxy(props.accountDetails.loggedinUserId).then( (data) => { - let showWatchedCases = (submittedArr) => { - const required = submittedArr.value.filter((el) => { - return ( - el.pinswg_representationsubmitted === - null || - el.pinswg_representationsubmitted === - undefined - ); - }); - - let newObj = {}; - return Object.assign(newObj, { - "@odata.count": required.length, - "value": required - }); - }; - - data = showWatchedCases(data); + data = splitWatchedCasesBySubmissionState( + data.value + ).watchedCases; data.value.sort(function compare(a, b) { var dateA = new Date(a.createdon); diff --git a/components/search/searchresults.js b/components/search/searchresults.js index 0154fad0..8d052db3 100644 --- a/components/search/searchresults.js +++ b/components/search/searchresults.js @@ -41,6 +41,7 @@ import { sendGAEvent } from "@next/third-parties/google"; import RepsOnResults from "./repsonresults"; import LiveLink from "./liveLink"; import { query } from "jsonpath"; +import { splitWatchedCasesBySubmissionState } from "../../lib/domain/dashboard-policy/splitWatchedCasesBySubmissionState"; const escapeRegExp = (string) => string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -220,21 +221,9 @@ const SearchResults = (props) => { getWatchedCasesProxy( props.accountDetails.loggedinUserId ).then((data) => { - let showWatchedCases = (submittedArr) => { - const required = submittedArr.value.filter((el) => { - return ( - el.pinswg_representationsubmitted == null - ); - }); - - let newObj = {}; - return Object.assign(newObj, { - "@odata.count": required.length, - "value": required - }); - }; - - data = showWatchedCases(data); + data = splitWatchedCasesBySubmissionState( + data.value + ).watchedCases; data.value.sort(function compare(a, b) { var dateA = new Date(a.createdon); diff --git a/lib/domain/dashboard-policy/README.md b/lib/domain/dashboard-policy/README.md new file mode 100644 index 00000000..698b1de4 --- /dev/null +++ b/lib/domain/dashboard-policy/README.md @@ -0,0 +1,151 @@ +# Dashboard Policy Boundary + +## Current ownership + +This boundary currently owns: + +```js +splitWatchedCasesBySubmissionState(records); +``` + +Purpose: + +```text +watched-case records + ↓ +classification by submission state + ↓ +watched bucket +submitted bucket +``` + +## Current contract + +`splitWatchedCasesBySubmissionState(records)` intentionally preserves: + +- loose-null semantics +- null handling +- undefined handling +- truthy submitted markers +- date-string submitted markers +- input-order preservation +- watched bucket count calculation + +In current behaviour, `pinswg_representationsubmitted == null` means a record remains in the watched bucket, so both `null` and `undefined` are treated as “not submitted”. Any non-null submitted marker, including booleans and date strings, is classified into the submitted bucket. + +## Output contract + +Current return shape: + +```js +{ + watchedCases: { + "@odata.count": number, + value: [...] + }, + + submittedRepresentations: [...] +} +``` + +The asymmetry of this shape is intentional and must be preserved until an explicit contract-change initiative occurs. + +## Explicit non-goals + +This boundary does **not** own: + +- sorting +- ordering rules +- createdon sorting +- createdDate sorting +- ticketnumber sorting +- top-three truncation +- detail merging +- hydration +- watch/unwatch orchestration +- setState behaviour +- React rendering +- dashboard cards +- search result rendering +- CRM queries +- API routes +- authentication +- authorization + +## Caller-owned behaviour + +Current pattern: + +```text +classification belongs to dashboard-policy +ordering belongs to the caller +refresh orchestration belongs to the caller +rendering belongs to the caller +``` + +Current examples: + +- `pages/myportal/index.js` + - uses `splitWatchedCasesBySubmissionState(watchedCases.value)` to derive watched and submitted buckets for store hydration. + - caller remains responsible for page-level orchestration and dispatch behaviour. + +- `components/myportal/viewall.js` + - uses helper classification for watched-case membership. + - caller still owns descending `createdon` sorting in the refresh path, current view refresh flow, and rendering. + +- `components/myportal/topthree.js` + - uses helper classification for watched-case membership. + - caller still owns created-date derivation, `createdDate` sorting, `ticketnumber` sorting, detail merging, final `createdon` ordering, and top-three truncation. + +- `components/search/searchresults.js` + - uses helper classification only for the watched-case delete refresh path. + - caller still owns descending `createdon` sorting after classification, watch/unwatch refresh orchestration, state updates, detail hydration, and result rendering. + +## Adopted consumers + +Current known adopters: + +- `pages/myportal/index.js` +- `components/myportal/viewall.js` +- `components/myportal/topthree.js` +- `components/search/searchresults.js` + +## Remaining consumers + +Likely future adoption candidates: + +- `components/search/addresssearchresults.js` +- `components/search/dnssearchresults.js` +- `components/case/summary.js` + +These consumers require characterization before adoption. + +## Testing expectations + +Current characterization safety net: + +```bash +node tests/phase22/dashboard-watched-case-classification.test.cjs +node tests/phase22/dashboard-viewall-classification.test.cjs +node tests/phase22/dashboard-topthree-classification.test.cjs +node tests/phase22/dashboard-searchresults-classification.test.cjs +npm run lint +``` + +## Future evolution + +Future work may: + +```text +increase helper adoption +``` + +Future work should not: + +```text +move sorting into the helper +move rendering into the helper +move refresh orchestration into the helper +``` + +without a separate domain-boundary decision. diff --git a/lib/domain/dashboard-policy/index.js b/lib/domain/dashboard-policy/index.js new file mode 100644 index 00000000..40f20513 --- /dev/null +++ b/lib/domain/dashboard-policy/index.js @@ -0,0 +1 @@ +export { splitWatchedCasesBySubmissionState } from "./splitWatchedCasesBySubmissionState"; diff --git a/lib/domain/dashboard-policy/splitWatchedCasesBySubmissionState.js b/lib/domain/dashboard-policy/splitWatchedCasesBySubmissionState.js new file mode 100644 index 00000000..ff3f1c02 --- /dev/null +++ b/lib/domain/dashboard-policy/splitWatchedCasesBySubmissionState.js @@ -0,0 +1,25 @@ +/** + * This helper owns watched-case classification by representation submission state. + * + * It intentionally preserves loose-null semantics because existing dashboard + * behaviour treats both null and undefined as "not submitted". + */ +export function splitWatchedCasesBySubmissionState(records = []) { + const watchedCases = { + "@odata.count": records.filter( + (record) => record.pinswg_representationsubmitted == null + ).length, + value: records.filter( + (record) => record.pinswg_representationsubmitted == null + ) + }; + + const submittedRepresentations = records.filter( + (record) => record.pinswg_representationsubmitted != null + ); + + return { + watchedCases, + submittedRepresentations + }; +} diff --git a/pages/myportal/index.js b/pages/myportal/index.js index f9c3b496..be202731 100644 --- a/pages/myportal/index.js +++ b/pages/myportal/index.js @@ -25,6 +25,7 @@ import { getMyLPACases, getWatchedCases } from "../../actions/services/portalService"; +import { splitWatchedCasesBySubmissionState } from "../../lib/domain/dashboard-policy"; import Breadcrumbs from "../../components/breadcrumbs"; import CookieBanner from "../../components/cookieBanner"; @@ -306,20 +307,10 @@ export const getServerSideProps = wrapper.getServerSideProps( const showReps = Boolean(process.env.SHOWREPRESENTATIONS); store.dispatch(setShowReps(showReps, showLoginCheck)); - // watched cases that are NOT submitted - const filteredWatchedCases = { - "@odata.count": watchedCases.value.filter( - (c) => c.pinswg_representationsubmitted == null - ).length, - value: watchedCases.value.filter( - (c) => c.pinswg_representationsubmitted == null - ) - }; - - // submitted reps derived from watched cases - const mySubmittedReps = watchedCases.value.filter( - (el) => el.pinswg_representationsubmitted != null - ); + const { + watchedCases: filteredWatchedCases, + submittedRepresentations: mySubmittedReps + } = splitWatchedCasesBySubmissionState(watchedCases.value); // Newer getDetails implementation (bounded concurrency, fewer edge-case bugs) const getDetails = async (resultsObj, detailsType) => { diff --git a/tests/phase22/dashboard-searchresults-classification.test.cjs b/tests/phase22/dashboard-searchresults-classification.test.cjs new file mode 100644 index 00000000..d62a326e --- /dev/null +++ b/tests/phase22/dashboard-searchresults-classification.test.cjs @@ -0,0 +1,293 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); +const normalizeForAssertion = (value) => + JSON.parse(JSON.stringify(value ?? null)); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const loadDashboardPolicyModule = () => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "dashboard-policy", + "splitWatchedCasesBySubmissionState.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /export function\s+splitWatchedCasesBySubmissionState/, + "function splitWatchedCasesBySubmissionState" + ); + source += ` +module.exports = { + splitWatchedCasesBySubmissionState +}; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const { splitWatchedCasesBySubmissionState } = loadDashboardPolicyModule(); + +const classifySearchResultsDeleteRefreshPath = (submittedArr) => { + const data = splitWatchedCasesBySubmissionState( + submittedArr.value + ).watchedCases; + + data.value.sort(function compare(a, b) { + var dateA = new Date(a.createdon); + var dateB = new Date(b.createdon); + return dateB - dateA; + }); + + return data; +}; + +const buildRecord = ( + id, + pinswg_representationsubmitted, + createdon, + extra = {} +) => ({ + id, + pinswg_representationsubmitted, + createdon, + ...extra +}); + +test("searchresults classification keeps null in watched cases bucket", () => { + const submittedArr = { + value: [buildRecord("case-null", null, "2024-01-01T00:00:00.000Z")] + }; + + const result = normalizeForAssertion( + classifySearchResultsDeleteRefreshPath(submittedArr) + ); + + assert.deepStrictEqual(result, { + "@odata.count": 1, + value: [ + { + id: "case-null", + pinswg_representationsubmitted: null, + createdon: "2024-01-01T00:00:00.000Z" + } + ] + }); +}); + +test("searchresults classification keeps undefined in watched cases bucket", () => { + const submittedArr = { + value: [ + buildRecord("case-undefined", undefined, "2024-01-01T00:00:00.000Z") + ] + }; + + const result = normalizeForAssertion( + classifySearchResultsDeleteRefreshPath(submittedArr) + ); + + assert.deepStrictEqual(result, { + "@odata.count": 1, + value: [ + { + id: "case-undefined", + createdon: "2024-01-01T00:00:00.000Z" + } + ] + }); +}); + +test("searchresults classification excludes date-string submitted marker from watched cases bucket", () => { + const submittedArr = { + value: [ + buildRecord("case-date", "2024-01-01", "2024-01-01T00:00:00.000Z") + ] + }; + + const result = normalizeForAssertion( + classifySearchResultsDeleteRefreshPath(submittedArr) + ); + + assert.deepStrictEqual(result, { + "@odata.count": 0, + value: [] + }); +}); + +test("searchresults classification excludes truthy submitted marker from watched cases bucket", () => { + const submittedArr = { + value: [buildRecord("case-true", true, "2024-01-01T00:00:00.000Z")] + }; + + const result = normalizeForAssertion( + classifySearchResultsDeleteRefreshPath(submittedArr) + ); + + assert.deepStrictEqual(result, { + "@odata.count": 0, + value: [] + }); +}); + +test("searchresults mixed collection preserves watched membership, count, and excludes submitted records", () => { + const submittedArr = { + value: [ + buildRecord("case-null", null, "2024-01-02T00:00:00.000Z"), + buildRecord( + "case-undefined", + undefined, + "2024-01-04T00:00:00.000Z" + ), + buildRecord("case-date", "2024-01-01", "2024-01-03T00:00:00.000Z"), + buildRecord("case-true", true, "2024-01-05T00:00:00.000Z") + ] + }; + + const result = normalizeForAssertion( + classifySearchResultsDeleteRefreshPath(submittedArr) + ); + + assert.strictEqual(result["@odata.count"], 2); + assert.deepStrictEqual( + result.value.map((item) => item.id), + ["case-undefined", "case-null"] + ); +}); + +test("searchresults applies sorting after filtering rather than preserving original filtered order", () => { + const submittedArr = { + value: [ + buildRecord("watched-earlier", null, "2024-01-01T00:00:00.000Z"), + buildRecord( + "submitted-middle", + "2024-01-10", + "2024-01-10T00:00:00.000Z" + ), + buildRecord("watched-later", undefined, "2024-01-03T00:00:00.000Z") + ] + }; + + const result = normalizeForAssertion( + classifySearchResultsDeleteRefreshPath(submittedArr) + ); + + assert.deepStrictEqual( + result.value.map((item) => item.id), + ["watched-later", "watched-earlier"] + ); +}); + +test("searchresults watched-case classification is equivalent to dashboard policy helper watched bucket for representative inputs", () => { + const records = [ + buildRecord("case-null", null, "2024-01-01T00:00:00.000Z"), + buildRecord("case-undefined", undefined, "2024-01-02T00:00:00.000Z"), + buildRecord("case-date", "2024-01-01", "2024-01-03T00:00:00.000Z"), + buildRecord("case-true", true, "2024-01-04T00:00:00.000Z") + ]; + + const searchResult = normalizeForAssertion( + classifySearchResultsDeleteRefreshPath({ value: records }) + ); + const helperResult = normalizeForAssertion( + splitWatchedCasesBySubmissionState(records) + ); + + assert.deepStrictEqual( + searchResult.value.map((item) => item.id), + ["case-undefined", "case-null"] + ); + assert.deepStrictEqual( + helperResult.watchedCases.value.map((item) => item.id), + ["case-null", "case-undefined"] + ); + assert.deepStrictEqual( + helperResult.submittedRepresentations.map((item) => item.id), + ["case-date", "case-true"] + ); + assert.strictEqual( + searchResult["@odata.count"], + helperResult.watchedCases["@odata.count"] + ); +}); + +test("searchresults output shape is a watched bucket object with odata count and value array only", () => { + const submittedArr = { + value: [ + buildRecord("case-null", null, "2024-01-01T00:00:00.000Z"), + buildRecord("case-date", "2024-01-01", "2024-01-03T00:00:00.000Z") + ] + }; + + const result = normalizeForAssertion( + classifySearchResultsDeleteRefreshPath(submittedArr) + ); + + assert.deepStrictEqual(Object.keys(result), ["@odata.count", "value"]); + assert.deepStrictEqual( + result.value.map((item) => item.id), + ["case-null"] + ); +}); + +test("searchresults watched-case classification is coupled to delete refresh and downstream details refresh, not result card rendering", () => { + const searchResultsSource = fs.readFileSync( + path.join(rootDir, "components", "search", "searchresults.js"), + "utf8" + ); + + assert.match( + searchResultsSource, + /topThreeType == "watchedCases"[\s\S]*?splitWatchedCasesBySubmissionState\([\s\S]*?\.watchedCases/ + ); + assert.match( + searchResultsSource, + /data\.value\.sort\(function compare\(a, b\)/ + ); + assert.match(searchResultsSource, /deleteWatchedCases\(caseID\)/); + assert.match(searchResultsSource, /getWatchedCasesProxy\(/); + assert.match(searchResultsSource, /setWatchedCases\(data\)/); + assert.match( + searchResultsSource, + /getDetailsProxy\(data, "myWatchedCases"\)/ + ); + assert.doesNotMatch( + searchResultsSource, + /resultsArr\.map\([\s\S]*pinswg_representationsubmitted/ + ); +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 dashboard searchresults classification tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/phase22/dashboard-topthree-classification.test.cjs b/tests/phase22/dashboard-topthree-classification.test.cjs new file mode 100644 index 00000000..cf8438ee --- /dev/null +++ b/tests/phase22/dashboard-topthree-classification.test.cjs @@ -0,0 +1,388 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); +const normalizeForAssertion = (value) => + JSON.parse(JSON.stringify(value ?? null)); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const loadDashboardPolicyModule = () => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "dashboard-policy", + "splitWatchedCasesBySubmissionState.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /export function\s+splitWatchedCasesBySubmissionState/, + "function splitWatchedCasesBySubmissionState" + ); + source += ` +module.exports = { + splitWatchedCasesBySubmissionState +}; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const { splitWatchedCasesBySubmissionState } = loadDashboardPolicyModule(); + +const classifyTopThreeDeletePath = (submittedArr) => { + const required = submittedArr.value.filter((el) => { + return el.pinswg_representationsubmitted == null; + }); + + let newObj = {}; + return Object.assign(newObj, { + "@odata.count": required.length, + value: required + }); +}; + +const sortByISODate = (arr, dateKey) => { + return arr.sort((a, b) => new Date(b[dateKey]) - new Date(a[dateKey])); +}; + +const sortByField = (arr, field, ascending = true) => { + return arr.sort((a, b) => { + if (a[field] < b[field]) return ascending ? -1 : 1; + if (a[field] > b[field]) return ascending ? 1 : -1; + return 0; + }); +}; + +const applyTopThreeProjection = (showTopThree, showDetails) => { + let showTopThreeArr = showTopThree.value; + + Object.keys(showTopThreeArr).map((key) => { + let createdDate = parseInt(showTopThreeArr[key].createdon); + showTopThreeArr[key].createdDate = createdDate; + }); + + showTopThreeArr.sort(function compare(a, b) { + var dateA = new Date(a.createdDate); + var dateB = new Date(b.createdDate); + return dateB - dateA; + }); + + let showTopThreeArrDets = showDetails; + + showTopThreeArr = sortByField(showTopThreeArr, "ticketnumber"); + showTopThreeArrDets = sortByField(showTopThreeArrDets, "pinswg_name"); + showTopThreeArrDets = showTopThreeArrDets.flatMap((item) => item.value); + + let mergedArray = showTopThreeArr.map((item1) => { + let item2 = showTopThreeArrDets.find( + (item) => item.pinswg_name === item1.ticketnumber + ); + return { ...item1, ...item2 }; + }); + + showTopThreeArr = mergedArray; + showTopThreeArr = sortByISODate(showTopThreeArr, "createdon"); + + return showTopThreeArr.slice(0, 3); +}; + +const buildRecord = ( + id, + pinswg_representationsubmitted, + createdon, + ticketnumber +) => ({ + id, + pinswg_representationsubmitted, + createdon, + ticketnumber +}); + +const buildDetailWrapper = (pinswg_name, extra = {}) => ({ + value: [ + { + pinswg_name, + ...extra + } + ] +}); + +test("topthree delete-path classification keeps null in watched cases bucket", () => { + const submittedArr = { + value: [ + buildRecord("case-null", null, "2024-01-01T00:00:00.000Z", "CAS-1") + ] + }; + + const result = normalizeForAssertion( + classifyTopThreeDeletePath(submittedArr) + ); + + assert.deepStrictEqual(result, { + "@odata.count": 1, + value: [ + { + id: "case-null", + pinswg_representationsubmitted: null, + createdon: "2024-01-01T00:00:00.000Z", + ticketnumber: "CAS-1" + } + ] + }); +}); + +test("topthree delete-path classification keeps undefined in watched cases bucket", () => { + const submittedArr = { + value: [ + buildRecord( + "case-undefined", + undefined, + "2024-01-01T00:00:00.000Z", + "CAS-1" + ) + ] + }; + + const result = normalizeForAssertion( + classifyTopThreeDeletePath(submittedArr) + ); + + assert.deepStrictEqual(result, { + "@odata.count": 1, + value: [ + { + id: "case-undefined", + createdon: "2024-01-01T00:00:00.000Z", + ticketnumber: "CAS-1" + } + ] + }); +}); + +test("topthree delete-path classification excludes date-string submitted marker from watched cases bucket", () => { + const submittedArr = { + value: [ + buildRecord( + "case-date", + "2024-01-01", + "2024-01-01T00:00:00.000Z", + "CAS-1" + ) + ] + }; + + const result = normalizeForAssertion( + classifyTopThreeDeletePath(submittedArr) + ); + + assert.deepStrictEqual(result, { + "@odata.count": 0, + value: [] + }); +}); + +test("topthree delete-path classification excludes truthy submitted marker from watched cases bucket", () => { + const submittedArr = { + value: [ + buildRecord("case-true", true, "2024-01-01T00:00:00.000Z", "CAS-1") + ] + }; + + const result = normalizeForAssertion( + classifyTopThreeDeletePath(submittedArr) + ); + + assert.deepStrictEqual(result, { + "@odata.count": 0, + value: [] + }); +}); + +test("topthree delete-path mixed collection preserves watched membership and count", () => { + const submittedArr = { + value: [ + buildRecord("case-null", null, "2024-01-01T00:00:00.000Z", "CAS-1"), + buildRecord( + "case-undefined", + undefined, + "2024-01-02T00:00:00.000Z", + "CAS-2" + ), + buildRecord( + "case-date", + "2024-01-01", + "2024-01-03T00:00:00.000Z", + "CAS-3" + ), + buildRecord("case-true", true, "2024-01-04T00:00:00.000Z", "CAS-4") + ] + }; + + const result = normalizeForAssertion( + classifyTopThreeDeletePath(submittedArr) + ); + + assert.strictEqual(result["@odata.count"], 2); + assert.deepStrictEqual( + result.value.map((item) => item.id), + ["case-null", "case-undefined"] + ); +}); + +test("topthree delete-path watched-case classification matches dashboard policy helper for representative inputs", () => { + const records = [ + buildRecord("case-null", null, "2024-01-01T00:00:00.000Z", "CAS-1"), + buildRecord( + "case-undefined", + undefined, + "2024-01-02T00:00:00.000Z", + "CAS-2" + ), + buildRecord( + "case-date", + "2024-01-01", + "2024-01-03T00:00:00.000Z", + "CAS-3" + ), + buildRecord("case-true", true, "2024-01-04T00:00:00.000Z", "CAS-4") + ]; + + const deletePathResult = normalizeForAssertion( + classifyTopThreeDeletePath({ value: records }) + ); + const helperResult = normalizeForAssertion( + splitWatchedCasesBySubmissionState(records) + ); + + assert.deepStrictEqual(deletePathResult, helperResult.watchedCases); +}); + +test("topthree watched-case refresh output shape only contains watched bucket object", () => { + const records = [ + buildRecord("case-null", null, "2024-01-01T00:00:00.000Z", "CAS-1"), + buildRecord( + "case-date", + "2024-01-01", + "2024-01-03T00:00:00.000Z", + "CAS-2" + ) + ]; + + const deletePathResult = normalizeForAssertion( + classifyTopThreeDeletePath({ value: records }) + ); + const helperResult = normalizeForAssertion( + splitWatchedCasesBySubmissionState(records) + ); + + assert.deepStrictEqual(Object.keys(deletePathResult), [ + "@odata.count", + "value" + ]); + assert.deepStrictEqual( + helperResult.submittedRepresentations.map((item) => item.id), + ["case-date"] + ); +}); + +test("topthree applies post-classification sorting, merging, and truncation to three items", () => { + const watchedCases = { + value: [ + buildRecord("case-1", null, "2024-01-01T00:00:00.000Z", "CAS-002"), + buildRecord( + "case-2", + undefined, + "2024-01-04T00:00:00.000Z", + "CAS-004" + ), + buildRecord("case-3", null, "2024-01-03T00:00:00.000Z", "CAS-003"), + buildRecord("case-4", null, "2024-01-02T00:00:00.000Z", "CAS-001") + ] + }; + + const showDetails = [ + buildDetailWrapper("CAS-001", { detailId: "detail-1" }), + buildDetailWrapper("CAS-002", { detailId: "detail-2" }), + buildDetailWrapper("CAS-003", { detailId: "detail-3" }), + buildDetailWrapper("CAS-004", { detailId: "detail-4" }) + ]; + + const classified = classifyTopThreeDeletePath(watchedCases); + const projected = normalizeForAssertion( + applyTopThreeProjection(classified, showDetails) + ); + + assert.deepStrictEqual( + projected.map((item) => item.ticketnumber), + ["CAS-004", "CAS-003", "CAS-001"] + ); + assert.strictEqual(projected.length, 3); + assert.deepStrictEqual( + projected.map((item) => item.detailId), + ["detail-4", "detail-3", "detail-1"] + ); +}); + +test("topthree classification does not itself sort filtered watched membership before the later projection pipeline", () => { + const submittedArr = { + value: [ + buildRecord("later", null, "2024-01-03T00:00:00.000Z", "CAS-9"), + buildRecord( + "submitted", + "2024-01-10", + "2024-01-10T00:00:00.000Z", + "CAS-8" + ), + buildRecord( + "earlier", + undefined, + "2024-01-01T00:00:00.000Z", + "CAS-7" + ) + ] + }; + + const result = normalizeForAssertion( + classifyTopThreeDeletePath(submittedArr) + ); + + assert.deepStrictEqual( + result.value.map((item) => item.id), + ["later", "earlier"] + ); +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 dashboard topthree classification tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/phase22/dashboard-viewall-classification.test.cjs b/tests/phase22/dashboard-viewall-classification.test.cjs new file mode 100644 index 00000000..1fc93e11 --- /dev/null +++ b/tests/phase22/dashboard-viewall-classification.test.cjs @@ -0,0 +1,295 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); +const normalizeForAssertion = (value) => + JSON.parse(JSON.stringify(value ?? null)); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const loadDashboardPolicyModule = () => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "dashboard-policy", + "splitWatchedCasesBySubmissionState.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /export function\s+splitWatchedCasesBySubmissionState/, + "function splitWatchedCasesBySubmissionState" + ); + source += ` +module.exports = { + splitWatchedCasesBySubmissionState +}; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const { splitWatchedCasesBySubmissionState } = loadDashboardPolicyModule(); + +const classifyViewAllDeletePath = (submittedArr) => { + const required = submittedArr.value.filter((el) => { + return el.pinswg_representationsubmitted == null; + }); + + let newObj = {}; + return Object.assign(newObj, { + "@odata.count": required.length, + value: required + }); +}; + +const classifyViewAllEmailRefreshPath = (submittedArr) => { + const required = submittedArr.value.filter((el) => { + return ( + el.pinswg_representationsubmitted === null || + el.pinswg_representationsubmitted === undefined + ); + }); + + let newObj = {}; + const data = Object.assign(newObj, { + "@odata.count": required.length, + value: required + }); + + data.value.sort(function compare(a, b) { + var dateA = new Date(a.createdon); + var dateB = new Date(b.createdon); + return dateB - dateA; + }); + + return data; +}; + +const buildRecord = (id, pinswg_representationsubmitted, createdon) => ({ + id, + pinswg_representationsubmitted, + createdon +}); + +test("viewall delete-path classification keeps null in watched cases bucket", () => { + const submittedArr = { + value: [buildRecord("case-null", null, "2024-01-01T00:00:00.000Z")] + }; + + const result = normalizeForAssertion( + classifyViewAllDeletePath(submittedArr) + ); + + assert.deepStrictEqual(result, { + "@odata.count": 1, + value: [ + { + id: "case-null", + pinswg_representationsubmitted: null, + createdon: "2024-01-01T00:00:00.000Z" + } + ] + }); +}); + +test("viewall delete-path classification keeps undefined in watched cases bucket", () => { + const submittedArr = { + value: [ + buildRecord("case-undefined", undefined, "2024-01-01T00:00:00.000Z") + ] + }; + + const result = normalizeForAssertion( + classifyViewAllDeletePath(submittedArr) + ); + + assert.deepStrictEqual(result, { + "@odata.count": 1, + value: [ + { + id: "case-undefined", + createdon: "2024-01-01T00:00:00.000Z" + } + ] + }); +}); + +test("viewall delete-path classification excludes date-string submitted marker from watched cases bucket", () => { + const submittedArr = { + value: [ + buildRecord("case-date", "2024-01-01", "2024-01-01T00:00:00.000Z") + ] + }; + + const result = normalizeForAssertion( + classifyViewAllDeletePath(submittedArr) + ); + + assert.deepStrictEqual(result, { + "@odata.count": 0, + value: [] + }); +}); + +test("viewall delete-path classification excludes truthy submitted marker from watched cases bucket", () => { + const submittedArr = { + value: [buildRecord("case-true", true, "2024-01-01T00:00:00.000Z")] + }; + + const result = normalizeForAssertion( + classifyViewAllDeletePath(submittedArr) + ); + + assert.deepStrictEqual(result, { + "@odata.count": 0, + value: [] + }); +}); + +test("viewall delete-path mixed collection preserves watched membership and count", () => { + const submittedArr = { + value: [ + buildRecord("case-null", null, "2024-01-01T00:00:00.000Z"), + buildRecord( + "case-undefined", + undefined, + "2024-01-02T00:00:00.000Z" + ), + buildRecord("case-date", "2024-01-01", "2024-01-03T00:00:00.000Z"), + buildRecord("case-true", true, "2024-01-04T00:00:00.000Z") + ] + }; + + const result = normalizeForAssertion( + classifyViewAllDeletePath(submittedArr) + ); + + assert.strictEqual(result["@odata.count"], 2); + assert.deepStrictEqual( + result.value.map((item) => item.id), + ["case-null", "case-undefined"] + ); +}); + +test("viewall delete-path preserves original filtered order", () => { + const submittedArr = { + value: [ + buildRecord("watched-first", null, "2024-01-01T00:00:00.000Z"), + buildRecord( + "submitted-middle", + "2024-01-10", + "2024-01-10T00:00:00.000Z" + ), + buildRecord("watched-second", undefined, "2024-01-02T00:00:00.000Z") + ] + }; + + const result = normalizeForAssertion( + classifyViewAllDeletePath(submittedArr) + ); + + assert.deepStrictEqual( + result.value.map((item) => item.id), + ["watched-first", "watched-second"] + ); +}); + +test("viewall email-refresh path preserves watched-case output shape and sorts descending by createdon", () => { + const submittedArr = { + value: [ + buildRecord("older-watched", null, "2024-01-01T00:00:00.000Z"), + buildRecord("newer-watched", undefined, "2024-01-02T00:00:00.000Z"), + buildRecord( + "submitted-item", + "2024-01-10", + "2024-01-10T00:00:00.000Z" + ) + ] + }; + + const result = normalizeForAssertion( + classifyViewAllEmailRefreshPath(submittedArr) + ); + + assert.deepStrictEqual(Object.keys(result), ["@odata.count", "value"]); + assert.strictEqual(result["@odata.count"], 2); + assert.deepStrictEqual( + result.value.map((item) => item.id), + ["newer-watched", "older-watched"] + ); +}); + +test("viewall delete-path watched-case classification matches dashboard policy helper for representative inputs", () => { + const records = [ + buildRecord("case-null", null, "2024-01-01T00:00:00.000Z"), + buildRecord("case-undefined", undefined, "2024-01-02T00:00:00.000Z"), + buildRecord("case-date", "2024-01-01", "2024-01-03T00:00:00.000Z"), + buildRecord("case-true", true, "2024-01-04T00:00:00.000Z") + ]; + + const deletePathResult = normalizeForAssertion( + classifyViewAllDeletePath({ value: records }) + ); + const helperResult = normalizeForAssertion( + splitWatchedCasesBySubmissionState(records) + ); + + assert.deepStrictEqual(deletePathResult, helperResult.watchedCases); +}); + +test("viewall currently has no submitted bucket output shape in its watched-case refresh logic", () => { + const records = [ + buildRecord("case-null", null, "2024-01-01T00:00:00.000Z"), + buildRecord("case-date", "2024-01-01", "2024-01-03T00:00:00.000Z") + ]; + + const deletePathResult = normalizeForAssertion( + classifyViewAllDeletePath({ value: records }) + ); + const helperResult = normalizeForAssertion( + splitWatchedCasesBySubmissionState(records) + ); + + assert.deepStrictEqual(Object.keys(deletePathResult), [ + "@odata.count", + "value" + ]); + assert.deepStrictEqual( + helperResult.submittedRepresentations.map((item) => item.id), + ["case-date"] + ); +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 dashboard viewall classification tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/phase22/dashboard-watched-case-classification.test.cjs b/tests/phase22/dashboard-watched-case-classification.test.cjs new file mode 100644 index 00000000..b6a16660 --- /dev/null +++ b/tests/phase22/dashboard-watched-case-classification.test.cjs @@ -0,0 +1,200 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); +const normalizeForAssertion = (value) => + JSON.parse(JSON.stringify(value ?? null)); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const loadDashboardPolicyModule = () => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "dashboard-policy", + "splitWatchedCasesBySubmissionState.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /export function\s+splitWatchedCasesBySubmissionState/, + "function splitWatchedCasesBySubmissionState" + ); + source += ` +module.exports = { + splitWatchedCasesBySubmissionState +}; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const { splitWatchedCasesBySubmissionState } = loadDashboardPolicyModule(); + +const classifyWatchedCases = (watchedCases) => { + const result = normalizeForAssertion( + splitWatchedCasesBySubmissionState(watchedCases.value) + ); + + return { + filteredWatchedCases: result.watchedCases, + mySubmittedReps: result.submittedRepresentations + }; +}; + +const buildRecord = (id, pinswg_representationsubmitted) => ({ + id, + pinswg_representationsubmitted +}); + +test("null submitted marker remains in watched cases bucket", () => { + const watchedCases = { + value: [buildRecord("case-null", null)] + }; + + const result = classifyWatchedCases(watchedCases); + + assert.strictEqual(result.filteredWatchedCases["@odata.count"], 1); + assert.deepStrictEqual( + result.filteredWatchedCases.value.map((item) => item.id), + ["case-null"] + ); + assert.deepStrictEqual(result.mySubmittedReps, []); +}); + +test("undefined submitted marker remains in watched cases bucket", () => { + const watchedCases = { + value: [buildRecord("case-undefined", undefined)] + }; + + const result = classifyWatchedCases(watchedCases); + + assert.strictEqual(result.filteredWatchedCases["@odata.count"], 1); + assert.deepStrictEqual( + result.filteredWatchedCases.value.map((item) => item.id), + ["case-undefined"] + ); + assert.deepStrictEqual(result.mySubmittedReps, []); +}); + +test("date-string submitted marker remains in submitted representations bucket", () => { + const watchedCases = { + value: [buildRecord("case-date", "2024-01-01")] + }; + + const result = classifyWatchedCases(watchedCases); + + assert.strictEqual(result.filteredWatchedCases["@odata.count"], 0); + assert.deepStrictEqual(result.filteredWatchedCases.value, []); + assert.deepStrictEqual( + result.mySubmittedReps.map((item) => item.id), + ["case-date"] + ); +}); + +test("truthy submitted marker remains in submitted representations bucket", () => { + const watchedCases = { + value: [buildRecord("case-true", true)] + }; + + const result = classifyWatchedCases(watchedCases); + + assert.strictEqual(result.filteredWatchedCases["@odata.count"], 0); + assert.deepStrictEqual(result.filteredWatchedCases.value, []); + assert.deepStrictEqual( + result.mySubmittedReps.map((item) => item.id), + ["case-true"] + ); +}); + +test("mixed collection preserves current split counts and membership", () => { + const watchedCases = { + value: [ + buildRecord("case-null", null), + buildRecord("case-undefined", undefined), + buildRecord("case-date", "2024-01-01"), + buildRecord("case-true", true) + ] + }; + + const result = classifyWatchedCases(watchedCases); + + assert.strictEqual(result.filteredWatchedCases["@odata.count"], 2); + assert.deepStrictEqual( + result.filteredWatchedCases.value.map((item) => item.id), + ["case-null", "case-undefined"] + ); + assert.deepStrictEqual( + result.mySubmittedReps.map((item) => item.id), + ["case-date", "case-true"] + ); +}); + +test("empty collection preserves empty watched and submitted outputs", () => { + const watchedCases = { + value: [] + }; + + const result = classifyWatchedCases(watchedCases); + + assert.deepStrictEqual(result.filteredWatchedCases, { + "@odata.count": 0, + value: [] + }); + assert.deepStrictEqual(result.mySubmittedReps, []); +}); + +test("classification preserves input order within both buckets", () => { + const watchedCases = { + value: [ + buildRecord("watched-first", null), + buildRecord("submitted-first", "2024-01-01"), + buildRecord("watched-second", undefined), + buildRecord("submitted-second", true) + ] + }; + + const result = classifyWatchedCases(watchedCases); + + assert.deepStrictEqual( + result.filteredWatchedCases.value.map((item) => item.id), + ["watched-first", "watched-second"] + ); + assert.deepStrictEqual( + result.mySubmittedReps.map((item) => item.id), + ["submitted-first", "submitted-second"] + ); +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 dashboard watched-case classification tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +}