diff --git a/docs/query-profile-compatibility.md b/docs/query-profile-compatibility.md new file mode 100644 index 00000000..bcea91c5 --- /dev/null +++ b/docs/query-profile-compatibility.md @@ -0,0 +1,308 @@ +# Query Profile Compatibility Contract + +## Purpose + +This document records the current compatibility contract around: + +- `actions/selectQueryTypes.js` + +That file is primarily: + +- **CRM/OData query-shape configuration** +- **CRM compatibility handling** + +It is **not primarily business-rule logic**. + +Its main job today is to ensure the portal requests the fields that each CRM entity actually exposes so downstream case-summary, representation, status, lifecycle, and infrastructure views continue to function. + +--- + +## Source of Truth + +The **CRM field names are the source of truth**. + +The website must query the fields that actually exist on each CRM entity. + +This includes preserving: + +- historic naming differences +- spelling mistakes +- singular/plural variants +- entity-specific field names +- legislation-specific field models + +Future work must treat field selection as a **compatibility-sensitive integration concern first**. + +The goal is not to make the query profiles look tidy. + +The goal is to preserve the fields that the live system currently depends on. + +--- + +## Appeal-Type Differences + +Different appeal types legitimately require different fields because they support different: + +- legislation +- workflows +- representation windows +- event models +- authority models +- infrastructure/project processes + +This means: + +- similar-looking appeal types must **not automatically be treated as identical** +- near-duplicate field bundles must **not automatically be collapsed** +- infrastructure-style entities must **not automatically be forced into planning-style shapes** +- minimal profiles must **not automatically be expanded to match richer bundles** + +Bundle-by-bundle evaluation is required. + +--- + +## CRM Compatibility Variants + +The current compatibility contract includes representative field variants that must be preserved. + +### Specialist-process variants + +- `pinswg_specialistcaseprocess` +- `pinswg_speacialistcaseprocess` + +These are not interchangeable in the current query profiles. + +Representative characterization showed: + +- ROW uses `pinswg_specialistcaseprocess` +- Adverts uses `pinswg_speacialistcaseprocess` + +### Start-date variants + +- `pinswg_startdate` +- `pinswg_startdates` +- `pinswg_applicationacceptedasvalid` + +These reflect different representation-window and process models across entities. + +### Statement-date variants + +- `pinswg_statementduedate` +- `pinswg_statementsduedate` + +These must not be normalized away. + +### Project description variants + +- `pinswg_projectdescription` +- `pinswg_projectdiscription` + +Harbour Revision Orders currently preserve the misspelled variant. + +### Decision variant + +- `pinswg_dicision` + +Planning S78 currently preserves this field name in its query profile. + +### Other representative compatibility-sensitive variants + +- `pinswg_otherpartiesstatement` +- `pinswg_otherpartiesstatements` +- `pinswg_otherpartiesstmt` + +- `pinswg_suspensionstartdates` +- `pinswg_suspensionstartdate` + +- `pinswg_startdateofevent` +- `pinswg_startdateoftheevent` +- `pinswg_starttimeofevent` +- `pinswg_starttimeoftheevent` +- `pinswg_startdatetimeiftheevent` + +These variants must be preserved until characterization-based extraction or change work explicitly proves otherwise. + +--- + +## Characterized Field Families + +The current characterization work identifies the following conceptual families already present inside `selectQueryTypes.js`. + +### 1. Planning-style fields + +Representative concerns include: + +- base case identity fields +- planning-style address fields +- decision/date/status fields +- event support fields + +These appear across many planning and enforcement-style entities, but not always with identical field names. + +### 2. Representation-window fields + +Representative fields include: + +- `pinswg_startdate` +- `pinswg_startdates` +- `pinswg_applicationacceptedasvalid` +- `pinswg_statementduedate` +- `pinswg_statementsduedate` +- `pinswg_finalcommentsduedate` +- `pinswg_endofrepresentationperiod` + +These coexist today and reflect different entity models. + +### 3. Specialist-process fields + +Representative fields include: + +- `pinswg_specialistcaseprocess` +- `pinswg_speacialistcaseprocess` + +These must not be silently unified. + +### 4. Infrastructure / project fields + +Representative concerns include: + +- project description +- project location +- submission / publication milestones +- recommendation / report fields +- suspension dates +- grid-reference fields + +These are particularly important in DNS / Harbour / Transport / Electricity style bundles. + +### 5. Authority / LPA fields + +Representative fields include: + +- `_pinswg_localplanningauthority_value` +- `_pinswg_associatedlpa_value` +- `pinswg_relevantauthorityname` + +These represent different authority models across bundles. + +### 6. Consultation fields + +Representative fields include: + +- `pinswg_consultationopen` +- `pinswg_consultationclose` + +These are currently characteristic of SIPS-style profiles. + +### 7. Minimal-profile bundles + +Representative examples include: + +- `pinswg_nonvalidations` +- `pinswg_ldps` + +These are intentionally smaller than richer planning and infrastructure bundles. + +--- + +## Characterized Divergences + +Characterization work also confirmed that some similar bundles intentionally differ. + +Representative examples: + +### Advert vs ROW specialist-process fields + +- Advert preserves `pinswg_speacialistcaseprocess` +- ROW preserves `pinswg_specialistcaseprocess` + +These must not be accidentally collapsed into one shared field choice. + +### Harbour vs DNS project-description fields + +- Harbour preserves `pinswg_projectdiscription` +- DNS preserves `pinswg_projectdescription` + +### Suspension-start variants + +- DNS preserves `pinswg_suspensionstartdates` +- Harbour and Transport preserve `pinswg_suspensionstartdate` + +### Authority / LPA model differences + +- S78 preserves `_pinswg_localplanningauthority_value` +- ROW preserves `_pinswg_associatedlpa_value` plus `pinswg_relevantauthorityname` +- DNS preserves `_pinswg_associatedlpa_value` without the planning-style relevant-authority field + +### Event-date and event-time variants + +Representative coexistence includes: + +- `pinswg_startdateofevent` +- `pinswg_startdateoftheevent` +- `pinswg_starttimeofevent` +- `pinswg_starttimeoftheevent` +- `pinswg_startdatetimeiftheevent` + +### Representation-support differences in similar bundles + +Representative differences include: + +- singular vs plural other-parties fields +- singular vs plural statement-due-date fields +- bundle-specific combinations of proof/questionnaire/final-comments fields + +These differences must be treated as compatibility-sensitive unless explicitly re-characterized. + +--- + +## Future Guidance + +Future work in this area should follow these rules: + +1. **Characterize first** + - prove current behaviour before changing structure + +2. **Extract later** + - do not extract shared bundles or constants until the current compatibility contract is protected + +3. **Preserve compatibility** + - CRM field variants are not “cleanup noise”; they are part of the current live contract + +4. **Evaluate bundle-by-bundle** + - each bundle should be assessed on its own actual field requirements + +5. **Avoid broad normalization** + - do not assume similar names mean safe consolidation + +6. **Protect intentional differences** + - especially for: + - specialist-process fields + - representation-window fields + - project-description fields + - suspension-start fields + - authority/LPA fields + - event-date/time fields + +The safest sequence remains: + +- characterize current bundles +- characterize current families +- characterize duplicated clusters and divergences +- only then consider bounded extraction work + +--- + +## Explicit Non-Goals + +This characterization and documentation work does **not**: + +- rename CRM fields +- remove typo fields +- redesign CRM entities +- standardize legislation-specific data +- force appeal types into identical query profiles +- introduce a query DSL +- move this concern into a broader business-rule domain boundary + +Any future cleanup or extraction must preserve the documented compatibility contract unless an explicit behaviour-change decision is made. diff --git a/tests/phase22/select-query-types-duplicated-clusters.test.cjs b/tests/phase22/select-query-types-duplicated-clusters.test.cjs new file mode 100644 index 00000000..323cd664 --- /dev/null +++ b/tests/phase22/select-query-types-duplicated-clusters.test.cjs @@ -0,0 +1,456 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const loadSelectQueryTypesModule = (relayPath) => { + const filePath = path.join(rootDir, "actions", "selectQueryTypes.js"); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /export const\s+getSelectQuery\s*=\s*/, + "const getSelectQuery = " + ); + source += ` +module.exports = { + getSelectQuery +}; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require, + process: { + env: { + RELAYPATH: relayPath + } + } + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +const splitFields = (query) => { + assert.ok( + query.startsWith("&$select="), + `Expected query to start with &$select= but received: ${query}` + ); + + return query + .replace(/^&\$select=/, "") + .split(",") + .map((field) => field.trim()) + .filter(Boolean); +}; + +const getFieldSet = (appealTypeName, relayPath) => + new Set( + splitFields( + loadSelectQueryTypesModule(relayPath).getSelectQuery(appealTypeName) + ) + ); + +const assertHasFields = (fieldSet, expectedFields, label) => { + for (const field of expectedFields) { + assert.ok(fieldSet.has(field), `${label}: expected field \`${field}\``); + } +}; + +test("planning-style near-clones preserve a shared base cluster while retaining intentional representation-field differences", () => { + const planningConditions = getFieldSet("pinswg_planningconditionss73s79s"); + const planningObligation = getFieldSet( + "pinswg_planningobligationappeals106s" + ); + const enforcement = getFieldSet("pinswg_enforcementnoticeappeals174s"); + + const sharedPlanningCluster = [ + "modifiedon", + "_pinswg_appellant_value", + "pinswg_name", + "pinswg_siteaddressline1", + "pinswg_siteaddressline2", + "pinswg_siteaddresspostcode", + "pinswg_siteaddresstown", + "_pinswg_associatedlpa_value", + "pinswg_casedecisiondate", + "pinswg_dateeventrequested", + "pinswg_finalcommentsduedate", + "pinswg_procedure", + "pinswg_questionnaireduedate", + "pinswg_startdate", + "pinswg_statementduedate", + "statuscode" + ]; + + for (const [label, fields] of [ + ["Planning conditions", planningConditions], + ["Planning obligation", planningObligation], + ["Enforcement", enforcement] + ]) { + assertHasFields(fields, sharedPlanningCluster, label); + } + + assert.ok( + planningConditions.has("pinswg_otherpartiesstatement") && + !planningConditions.has("pinswg_otherpartiesstatements"), + "Planning conditions should preserve singular other-parties field variant" + ); + + assert.ok( + planningObligation.has("pinswg_otherpartiesstatements") && + !planningObligation.has("pinswg_otherpartiesstatement"), + "Planning obligation should preserve plural other-parties field variant" + ); + + assert.ok( + enforcement.has("pinswg_startdateoftheevent") && + !enforcement.has("pinswg_startdateofevent"), + "Enforcement should preserve its current event-date field variant" + ); +}); + +test("ROW and Hedges preserve a shared specialist/date/representation near-clone cluster", () => { + const rows = getFieldSet("pinswg_rows"); + const hedges = getFieldSet( + "pinswg_hedgeshedgerowstreepreservationreplacems" + ); + + const sharedRowHedgesCluster = [ + "pinswg_specialistcaseprocess", + "modifiedon", + "_pinswg_appellant_value", + "pinswg_name", + "_pinswg_associatedlpa_value", + "pinswg_casedecisiondate", + "pinswg_finalcommentsduedate", + "pinswg_otherpartiesstatement", + "pinswg_procedure", + "pinswg_proofsofevidencewrittenstatementsofeviden", + "pinswg_questionnaireduedate", + "pinswg_relevantauthorityname", + "pinswg_startdate", + "pinswg_startdateoftheevent", + "pinswg_typeofevent", + "pinswg_statementduedate", + "statuscode" + ]; + + assertHasFields(rows, sharedRowHedgesCluster, "ROW near-clone cluster"); + assertHasFields( + hedges, + sharedRowHedgesCluster, + "Hedges near-clone cluster" + ); + + assert.ok( + rows.has("pinswg_detailedeiascreeningrequired") && + !rows.has("pinswg_detailedeiascreening"), + "ROW should preserve detailed EIA screening required variant" + ); + + assert.ok( + hedges.has("pinswg_detailedeiascreening") && + !hedges.has("pinswg_detailedeiascreeningrequired"), + "Hedges should preserve detailed EIA screening variant without accidental normalization" + ); +}); + +test("Advert specialist divergence is preserved against ROW canonical specialist-process selection", () => { + const adverts = getFieldSet("pinswg_advertses"); + const rows = getFieldSet("pinswg_rows"); + + assert.ok( + adverts.has("pinswg_speacialistcaseprocess") && + !adverts.has("pinswg_specialistcaseprocess"), + "Adverts should preserve misspelled specialist-process field only" + ); + + assert.ok( + rows.has("pinswg_specialistcaseprocess") && + !rows.has("pinswg_speacialistcaseprocess"), + "ROW should preserve canonical specialist-process field only" + ); + + assertHasFields( + adverts, + [ + "pinswg_finalcommentsduedate", + "pinswg_questionnaireduedate", + "pinswg_statementduedate", + "pinswg_startdate", + "pinswg_startdateoftheevent" + ], + "Adverts specialist divergence support cluster" + ); + + assertHasFields( + rows, + [ + "pinswg_finalcommentsduedate", + "pinswg_questionnaireduedate", + "pinswg_statementduedate", + "pinswg_startdate", + "pinswg_startdateoftheevent" + ], + "ROW specialist divergence support cluster" + ); +}); + +test("infrastructure near-clones preserve shared milestone concepts and schema-specific divergences", () => { + const dns = getFieldSet("pinswg_dnses"); + const harbour = getFieldSet("pinswg_harbourrevisionorders"); + const transport = getFieldSet("pinswg_transportandworkacts"); + const electricity = getFieldSet("pinswg_electricityacts"); + + const sharedInfrastructureCluster = [ + "pinswg_eiarequired", + "pinswg_name", + "pinswg_addressline1", + "pinswg_addressline2", + "pinswg_postcode", + "pinswg_addresstown", + "pinswg_applicationacceptedasvalid", + "pinswg_confirmrequesttovaryapplication", + "pinswg_dateeventrequested", + "pinswg_dateofsubmissionofapplication", + "pinswg_dateprojectpublishedonwebsite", + "pinswg_deadlineforsubmissionofapplication", + "pinswg_endofrepresentationperiod", + "pinswg_inspectorreportsubmittedtowelshgovernment", + "pinswg_projectlocation", + "pinswg_recommendation", + "pinswg_rejectrequesttovaryapplication", + "pinswg_reportdueby", + "pinswg_suspensiondates", + "pinswg_validnoticeofintentiontosubmitanapplicati", + "pinswg_webaddress", + "pinswg_withdrawndate", + "pinswg_writtenacceptanceofnotification", + "pinswg_startdateofevent", + "pinswg_typeofevent", + "statuscode" + ]; + + for (const [label, fields] of [ + ["DNS", dns], + ["Harbour", harbour], + ["Transport", transport], + ["Electricity", electricity] + ]) { + assertHasFields(fields, sharedInfrastructureCluster, label); + } + + assert.ok( + dns.has("pinswg_projectdescription") && + !dns.has("pinswg_projectdiscription"), + "DNS should preserve canonical project description" + ); + + assert.ok( + harbour.has("pinswg_projectdiscription") && + !harbour.has("pinswg_projectdescription"), + "Harbour should preserve misspelled project description variant" + ); + + assert.ok( + transport.has("pinswg_projectdescription") && + !transport.has("pinswg_projectdiscription"), + "Transport should preserve canonical project description field" + ); + + assert.ok( + dns.has("pinswg_suspensionstartdates") && + !dns.has("pinswg_suspensionstartdate"), + "DNS should preserve plural suspension start field" + ); + + for (const [label, fields] of [ + ["Harbour", harbour], + ["Transport", transport] + ]) { + assert.ok( + fields.has("pinswg_suspensionstartdate") && + !fields.has("pinswg_suspensionstartdates"), + `${label} should preserve singular suspension start field variant` + ); + } + + assert.ok( + dns.has("pinswg_anticipatedgridreferenceeastingtext") && + dns.has("pinswg_anticipatedgridreferencenorthingtext"), + "DNS should preserve grid reference fields" + ); + + assert.ok( + harbour.has("pinswg_anticipatedgridreferenceeastingtext") && + harbour.has("pinswg_anticipatedgridreferencenorthingtext"), + "Harbour should preserve grid reference fields" + ); + + assert.ok( + !electricity.has("pinswg_anticipatedgridreferenceeastingtext") && + !electricity.has("pinswg_anticipatedgridreferencenorthingtext"), + "Electricity should preserve current absence of grid reference fields" + ); +}); + +test("representation-support cluster preserves questionnaire, statement, final-comments, and proof/other-parties variants", () => { + const rows = getFieldSet("pinswg_rows"); + const planningObligation = getFieldSet( + "pinswg_planningobligationappeals106s" + ); + const householder = getFieldSet("pinswg_householderappealhases"); + const commonLand = getFieldSet("pinswg_commonlands"); + + assertHasFields( + rows, + [ + "pinswg_questionnaireduedate", + "pinswg_statementduedate", + "pinswg_finalcommentsduedate", + "pinswg_proofsofevidencewrittenstatementsofeviden", + "pinswg_otherpartiesstatement" + ], + "ROW representation-support cluster" + ); + + assertHasFields( + planningObligation, + [ + "pinswg_questionnaireduedate", + "pinswg_statementduedate", + "pinswg_finalcommentsduedate", + "pinswg_otherpartiesstatements" + ], + "Planning obligation representation-support cluster" + ); + + assertHasFields( + householder, + [ + "pinswg_questionnaireduedate", + "pinswg_statementduedate", + "pinswg_finalcommentsduedate", + "pinswg_otherpartiesstmt" + ], + "Householder representation-support cluster" + ); + + assertHasFields( + commonLand, + [ + "pinswg_questionnaireduedate", + "pinswg_statementsduedate", + "pinswg_finalcommentsduedate", + "pinswg_proofsofevidencewrittenstatementsofeviden", + "pinswg_otherpartiesstatement" + ], + "Common land representation-support cluster" + ); + + assert.ok( + commonLand.has("pinswg_statementsduedate") && + !commonLand.has("pinswg_statementduedate"), + "Common land should preserve plural statement-due-date variant" + ); +}); + +test("event-date cluster preserves coexistence of start-date/time variants across representative bundles", () => { + const callIns = getFieldSet("pinswg_callinss77s"); + const adverts = getFieldSet("pinswg_advertses"); + const planningS78Dev = getFieldSet( + "pinswg_planningappeals78s", + "dev-pedw-hc" + ); + const listedBuilding = getFieldSet( + "pinswg_listedbuildingandconservationareaconsens" + ); + const commonLand = getFieldSet("pinswg_commonlands"); + + assert.ok( + callIns.has("pinswg_startdateofevent") && + !callIns.has("pinswg_startdateoftheevent"), + "Call-ins should preserve `pinswg_startdateofevent` variant" + ); + + assert.ok( + adverts.has("pinswg_startdateoftheevent") && + !adverts.has("pinswg_startdateofevent"), + "Adverts should preserve `pinswg_startdateoftheevent` variant" + ); + + assert.ok( + planningS78Dev.has("pinswg_startdatetimeiftheevent"), + "S78 should preserve datetime event variant" + ); + + assert.ok( + listedBuilding.has("pinswg_starttimeofevent") && + !listedBuilding.has("pinswg_starttimeoftheevent"), + "Listed building should preserve `pinswg_starttimeofevent` variant" + ); + + assert.ok( + commonLand.has("pinswg_starttimeoftheevent") && + !commonLand.has("pinswg_starttimeofevent"), + "Common land should preserve `pinswg_starttimeoftheevent` variant" + ); +}); + +test("authority and LPA cluster preserves current coexistence of local, associated, and relevant authority fields", () => { + const planningS78Dev = getFieldSet( + "pinswg_planningappeals78s", + "dev-pedw-hc" + ); + const rows = getFieldSet("pinswg_rows"); + const dns = getFieldSet("pinswg_dnses"); + + assert.ok( + planningS78Dev.has("_pinswg_localplanningauthority_value") && + !planningS78Dev.has("_pinswg_associatedlpa_value") && + !planningS78Dev.has("pinswg_relevantauthorityname"), + "S78 should preserve local-planning-authority-only behaviour" + ); + + assert.ok( + rows.has("_pinswg_associatedlpa_value") && + rows.has("pinswg_relevantauthorityname") && + !rows.has("_pinswg_localplanningauthority_value"), + "ROW should preserve overlap of associated LPA and relevant authority" + ); + + assert.ok( + dns.has("_pinswg_associatedlpa_value") && + !dns.has("pinswg_relevantauthorityname") && + !dns.has("_pinswg_localplanningauthority_value"), + "DNS should preserve associated-LPA-only authority behaviour" + ); +}); + +const run = async () => { + let passed = 0; + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 select-query-types duplicated-cluster 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/select-query-types-field-bundles.test.cjs b/tests/phase22/select-query-types-field-bundles.test.cjs new file mode 100644 index 00000000..12991561 --- /dev/null +++ b/tests/phase22/select-query-types-field-bundles.test.cjs @@ -0,0 +1,395 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const loadSelectQueryTypesModule = (relayPath) => { + const filePath = path.join(rootDir, "actions", "selectQueryTypes.js"); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /export const\s+getSelectQuery\s*=\s*/, + "const getSelectQuery = " + ); + source += ` +module.exports = { + getSelectQuery +}; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require, + process: { + env: { + RELAYPATH: relayPath + } + } + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +const splitFields = (query) => { + assert.ok( + query.startsWith("&$select="), + `Expected query to start with &$select= but received: ${query}` + ); + + return query + .replace(/^&\$select=/, "") + .split(",") + .map((field) => field.trim()) + .filter(Boolean); +}; + +const assertIncludesAll = (fields, requiredFields, message) => { + for (const field of requiredFields) { + assert.ok( + fields.includes(field), + `${message}: expected field \`${field}\` to be included` + ); + } +}; + +test("Planning S78 bundle preserves base, typo, S78-specific, and relaypath-dependent fields", () => { + const devMod = loadSelectQueryTypesModule("dev-pedw-hc"); + const nonDevMod = loadSelectQueryTypesModule("prod-like"); + + const devFields = splitFields( + devMod.getSelectQuery("pinswg_planningappeals78s") + ); + const nonDevFields = splitFields( + nonDevMod.getSelectQuery("pinswg_planningappeals78s") + ); + + assertIncludesAll( + devFields, + [ + "pinswg_eiarequired", + "modifiedon", + "_pinswg_appellant_value", + "pinswg_name", + "pinswg_siteaddressline1", + "pinswg_siteaddresspostcode", + "pinswg_siteaddresscounty", + "_pinswg_localplanningauthority_value", + "pinswg_casedecisiondate", + "pinswg_dicision", + "pinswg_questionnaireduedate", + "pinswg_startdate", + "pinswg_statementduedate", + "pinswg_startdatetimeiftheevent", + "pinswg_representation_period_end_date", + "pinswg_extend_representation_date", + "pinswg_typeofevent", + "statuscode" + ], + "S78 dev bundle" + ); + + assert.ok( + !devFields.includes("pinswg_developmentdescription"), + "S78 dev bundle should preserve current omission of extended appeal-form fields" + ); + + assertIncludesAll( + nonDevFields, + [ + "pinswg_eiarequired", + "modifiedon", + "_pinswg_appellant_value", + "pinswg_name", + "pinswg_dicision", + "pinswg_startdatetimeiftheevent", + "pinswg_developmentdescription", + "pinswg_whyisinspectorneededonsite", + "pinswg_outlinehealthandsafetyissuesonsite", + "pinswg_additionalappealsmadedetails", + "pinswg_chosenprocedureexplanation", + "pinswg_dateofapplication", + "pinswg_lpaapplicationreference", + "pinswg_caseworkreason", + "pinswg_dateoflpadecision", + "pinswg_areaofsiteinhectaresdec", + "pinswg_floorspaceinsquaremeters", + "pinswg_ownershipcertificate", + "pinswg_siteviewablefromroad", + "pinswg_costappliedforindicator", + "pinswg_s106unilateralsubmitted" + ], + "S78 non-dev bundle" + ); + + assert.ok( + !nonDevFields.includes("pinswg_representation_period_end_date"), + "S78 non-dev bundle should preserve current omission of dev-only representation-period fields" + ); +}); + +test("DNS bundle preserves project, representation-window, and process fields", () => { + const { getSelectQuery } = loadSelectQueryTypesModule(); + const fields = splitFields(getSelectQuery("pinswg_dnses")); + + assertIncludesAll( + fields, + [ + "pinswg_eiarequired", + "pinswg_anticipatedgridreferenceeastingtext", + "pinswg_anticipatedgridreferencenorthingtext", + "pinswg_name", + "pinswg_addressline1", + "pinswg_addresscounty", + "pinswg_applicationacceptedasvalid", + "pinswg_choiceofprocedure", + "pinswg_dateofdecision", + "pinswg_deadlineforsubmissionofapplication", + "pinswg_endofrepresentationperiod", + "pinswg_procedureconfirmed", + "pinswg_projectdescription", + "pinswg_projectlocation", + "pinswg_recommendation", + "pinswg_reportdueby", + "pinswg_suspensiondates", + "pinswg_suspensionenddates", + "pinswg_suspensionstartdates", + "pinswg_webaddress", + "pinswg_writtenacceptanceofnotification", + "_pinswg_dnsids_value", + "pinswg_startdateofevent", + "pinswg_typeofevent", + "statuscode" + ], + "DNS bundle" + ); +}); + +test("SIPS bundle preserves consultation and milestone fields", () => { + const { getSelectQuery } = loadSelectQueryTypesModule(); + const fields = splitFields(getSelectQuery("pinswg_sipses")); + + assertIncludesAll( + fields, + [ + "pinswg_casedeadline", + "_pinswg_projecttype_value", + "pinswg_eiarequired", + "pinswg_anticipatedgridrefeasting", + "pinswg_anticipatedgridrefnorthing", + "_pinswg_appellant_value", + "pinswg_name", + "_pinswg_associatedlpa_value", + "pinswg_choiceofprocedure", + "pinswg_projectdescription", + "pinswg_projectlocation", + "_pinswg_sipscase_value", + "pinswg_validnoticeofintentiontosubmit", + "pinswg_deadlineforsubmissionofapplication", + "pinswg_screeningstartdate", + "pinswg_eiatargetdate", + "pinswg_scopingstartdate", + "pinswg_scopingtargetdate", + "pinswg_scopingoutcomeissueddate", + "pinswg_consultationopen", + "pinswg_consultationclose", + "pinswg_reportissuedtowelshministers", + "pinswg_decisionissuedtoapplicant", + "pinswg_redeterminationconsultationdeadline" + ], + "SIPS bundle" + ); +}); + +test("ROW bundle preserves canonical specialist-process field and date/representation fields", () => { + const { getSelectQuery } = loadSelectQueryTypesModule(); + const fields = splitFields(getSelectQuery("pinswg_rows")); + + assertIncludesAll( + fields, + [ + "pinswg_detailedeiascreeningrequired", + "pinswg_specialistcaseprocess", + "modifiedon", + "_pinswg_appellant_value", + "pinswg_name", + "_pinswg_associatedlpa_value", + "pinswg_casedecisiondate", + "pinswg_finalcommentsduedate", + "pinswg_otherpartiesstatement", + "pinswg_procedure", + "pinswg_proofsofevidencewrittenstatementsofeviden", + "pinswg_questionnaireduedate", + "pinswg_relevantauthorityname", + "pinswg_startdate", + "pinswg_startdateoftheevent", + "pinswg_typeofevent", + "pinswg_statementduedate", + "statuscode" + ], + "ROW bundle" + ); + + assert.ok( + !fields.includes("pinswg_speacialistcaseprocess"), + "ROW bundle should preserve current absence of misspelled specialist-process field" + ); +}); + +test("Adverts bundle preserves misspelled specialist-process field and representation fields", () => { + const { getSelectQuery } = loadSelectQueryTypesModule(); + const fields = splitFields(getSelectQuery("pinswg_advertses")); + + assertIncludesAll( + fields, + [ + "pinswg_detailedeiascreeningrequired", + "pinswg_speacialistcaseprocess", + "modifiedon", + "_pinswg_appellant_value", + "pinswg_name", + "_pinswg_associatedlpa_value", + "pinswg_casedecisiondate", + "pinswg_dateeventrequested", + "pinswg_procedure", + "pinswg_questionnaireduedate", + "pinswg_startdateoftheevent", + "pinswg_startdate", + "pinswg_otherpartiesstatement", + "pinswg_finalcommentsduedate", + "pinswg_proofsofevidencewrittenstatementsofeviden", + "pinswg_statementduedate", + "statuscode" + ], + "Adverts bundle" + ); + + assert.ok( + !fields.includes("pinswg_specialistcaseprocess"), + "Adverts bundle should preserve current absence of canonical specialist-process field" + ); +}); + +test("Householder bundle preserves reduced field set and other-parties variant", () => { + const { getSelectQuery } = loadSelectQueryTypesModule(); + const fields = splitFields(getSelectQuery("pinswg_householderappealhases")); + + assertIncludesAll( + fields, + [ + "pinswg_eiarequired", + "modifiedon", + "_pinswg_appellant_value", + "pinswg_name", + "pinswg_siteaddressline1", + "pinswg_siteaddresspostcode", + "_pinswg_associatedlpa_value", + "pinswg_casedecisiondate", + "pinswg_dateeventrequested", + "pinswg_decision", + "pinswg_finalcommentsduedate", + "pinswg_otherpartiesstmt", + "pinswg_procedure", + "pinswg_questionnaireduedate", + "pinswg_startdate", + "pinswg_statementduedate", + "statuscode" + ], + "Householder bundle" + ); + + assert.ok( + !fields.includes("pinswg_startdateoftheevent") && + !fields.includes("pinswg_startdateofevent"), + "Householder bundle should preserve current omission of event-start fields" + ); +}); + +test("Infrastructure-style harbour bundle preserves project/date fields and schema variants", () => { + const { getSelectQuery } = loadSelectQueryTypesModule(); + const fields = splitFields(getSelectQuery("pinswg_harbourrevisionorders")); + + assertIncludesAll( + fields, + [ + "pinswg_eiarequired", + "pinswg_anticipatedgridreferenceeastingtext", + "pinswg_anticipatedgridreferencenorthingtext", + "pinswg_name", + "pinswg_addressline1", + "pinswg_addresscounty", + "pinswg_applicationacceptedasvalid", + "pinswg_choiceofprocedure", + "pinswg_dateofdecision", + "pinswg_endofrepresentationperiod", + "pinswg_projectdiscription", + "pinswg_projectlocation", + "pinswg_suspensionstartdate", + "pinswg_suspensionenddates", + "pinswg_validnoticeofintentiontosubmitanapplicati", + "pinswg_writtenacceptanceofnotification", + "pinswg_startdateofevent", + "pinswg_typeofevent", + "statuscode" + ], + "Harbour bundle" + ); +}); + +test("Minimal bundles preserve current nonvalidation and LDP behaviour", () => { + const { getSelectQuery } = loadSelectQueryTypesModule(); + + const nonValidationFields = splitFields( + getSelectQuery("pinswg_nonvalidations") + ); + assertIncludesAll( + nonValidationFields, + [ + "modifiedon", + "_pinswg_appellant_value", + "pinswg_name", + "pinswg_siteaddressline1", + "pinswg_siteaddressline2", + "pinswg_siteaddresspostcode", + "pinswg_siteaddresstown", + "_pinswg_associatedlpa_value", + "statuscode" + ], + "Nonvalidation bundle" + ); + assert.strictEqual( + nonValidationFields.includes("pinswg_casedecisiondate"), + false, + "Nonvalidation bundle should preserve current omission of decision-date fields" + ); + + const ldpFields = splitFields(getSelectQuery("pinswg_ldps")); + assert.deepStrictEqual(ldpFields, ["modifiedon", "pinswg_name"]); +}); + +const run = async () => { + let passed = 0; + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 select-query-types field bundle 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/select-query-types-field-families.test.cjs b/tests/phase22/select-query-types-field-families.test.cjs new file mode 100644 index 00000000..f5be430f --- /dev/null +++ b/tests/phase22/select-query-types-field-families.test.cjs @@ -0,0 +1,405 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const loadSelectQueryTypesModule = (relayPath) => { + const filePath = path.join(rootDir, "actions", "selectQueryTypes.js"); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /export const\s+getSelectQuery\s*=\s*/, + "const getSelectQuery = " + ); + source += ` +module.exports = { + getSelectQuery +}; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require, + process: { + env: { + RELAYPATH: relayPath + } + } + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +const splitFields = (query) => { + assert.ok( + query.startsWith("&$select="), + `Expected query to start with &$select= but received: ${query}` + ); + + return query + .replace(/^&\$select=/, "") + .split(",") + .map((field) => field.trim()) + .filter(Boolean); +}; + +const getFieldSet = (appealTypeName, relayPath) => + new Set( + splitFields( + loadSelectQueryTypesModule(relayPath).getSelectQuery(appealTypeName) + ) + ); + +const assertHasFields = (fieldSet, expectedFields, label) => { + for (const field of expectedFields) { + assert.ok(fieldSet.has(field), `${label}: expected field \`${field}\``); + } +}; + +test("planning-style family preserves common base, location, decision, and status fields across representative bundles", () => { + const s78Dev = getFieldSet("pinswg_planningappeals78s", "dev-pedw-hc"); + const rows = getFieldSet("pinswg_rows"); + const householder = getFieldSet("pinswg_householderappealhases"); + + const planningBaseFamily = [ + "modifiedon", + "_pinswg_appellant_value", + "pinswg_name", + "pinswg_dateeventrequested", + "statuscode" + ]; + + for (const [label, fields] of [ + ["S78 dev", s78Dev], + ["ROW", rows], + ["Householder", householder] + ]) { + assertHasFields(fields, planningBaseFamily, label); + } + + assertHasFields( + s78Dev, + [ + "pinswg_siteaddressline1", + "pinswg_siteaddressline2", + "pinswg_siteaddresspostcode", + "pinswg_siteaddresstown", + "pinswg_siteaddresscounty", + "pinswg_casedecisiondate", + "pinswg_dicision" + ], + "S78 dev planning family" + ); + + assertHasFields( + rows, + [ + "pinswg_siteaddressline1", + "pinswg_siteaddressline2", + "pinswg_siteaddresspostcode", + "pinswg_siteaddresstown", + "pinswg_casedecisiondate", + "pinswg_decision" + ], + "ROW planning family" + ); + + assertHasFields( + householder, + [ + "pinswg_siteaddressline1", + "pinswg_siteaddressline2", + "pinswg_siteaddresspostcode", + "pinswg_siteaddresstown", + "pinswg_casedecisiondate", + "pinswg_decision" + ], + "Householder planning family" + ); +}); + +test("representation-window family preserves current start and end date variants without normalization", () => { + const rows = getFieldSet("pinswg_rows"); + const enforcementListed = getFieldSet( + "pinswg_enforcementlistedbuildingconservationaps" + ); + const dns = getFieldSet("pinswg_dnses"); + const callIns = getFieldSet("pinswg_callinss77s"); + + assertHasFields( + rows, + [ + "pinswg_startdate", + "pinswg_statementduedate", + "pinswg_finalcommentsduedate" + ], + "ROW representation-window family" + ); + + assertHasFields( + enforcementListed, + [ + "pinswg_startdates", + "pinswg_statementduedate", + "pinswg_finalcommentsduedate" + ], + "Enforcement listed representation-window family" + ); + + assertHasFields( + dns, + [ + "pinswg_applicationacceptedasvalid", + "pinswg_endofrepresentationperiod" + ], + "DNS representation-window family" + ); + + assertHasFields( + callIns, + [ + "pinswg_startdate", + "pinswg_statementsduedate", + "pinswg_finalcommentsduedate" + ], + "Call-ins representation-window family" + ); + + assert.ok( + !dns.has("pinswg_startdate") && !dns.has("pinswg_startdates"), + "DNS should preserve infrastructure-style start-date variant rather than planning-style start fields" + ); +}); + +test("specialist-process family preserves intentional field-variant divergence across bundles", () => { + const rows = getFieldSet("pinswg_rows"); + const adverts = getFieldSet("pinswg_advertses"); + const hedges = getFieldSet( + "pinswg_hedgeshedgerowstreepreservationreplacems" + ); + const householder = getFieldSet("pinswg_householderappealhases"); + + assert.ok( + rows.has("pinswg_specialistcaseprocess") && + !rows.has("pinswg_speacialistcaseprocess"), + "ROW should preserve canonical-only specialist-process selection" + ); + + assert.ok( + adverts.has("pinswg_speacialistcaseprocess") && + !adverts.has("pinswg_specialistcaseprocess"), + "Adverts should preserve misspelled-only specialist-process selection" + ); + + assert.ok( + hedges.has("pinswg_specialistcaseprocess") && + !hedges.has("pinswg_speacialistcaseprocess"), + "Hedges should preserve canonical-only specialist-process selection" + ); + + assert.ok( + !householder.has("pinswg_specialistcaseprocess") && + !householder.has("pinswg_speacialistcaseprocess"), + "Householder should preserve absence of specialist-process fields" + ); +}); + +test("infrastructure-style family preserves project-oriented fields and schema variants across representative bundles", () => { + const dns = getFieldSet("pinswg_dnses"); + const harbour = getFieldSet("pinswg_harbourrevisionorders"); + const electricity = getFieldSet("pinswg_electricityacts"); + + const commonInfrastructureFields = [ + "pinswg_eiarequired", + "pinswg_name", + "pinswg_addressline1", + "pinswg_addresstown", + "pinswg_postcode", + "pinswg_applicationacceptedasvalid", + "pinswg_endofrepresentationperiod", + "pinswg_projectlocation", + "pinswg_recommendation", + "pinswg_reportdueby", + "pinswg_validnoticeofintentiontosubmitanapplicati", + "pinswg_writtenacceptanceofnotification", + "pinswg_startdateofevent", + "pinswg_typeofevent", + "statuscode" + ]; + + for (const [label, fields] of [ + ["DNS", dns], + ["Harbour", harbour], + ["Electricity", electricity] + ]) { + assertHasFields(fields, commonInfrastructureFields, label); + } + + assert.ok( + dns.has("pinswg_projectdescription") && + !dns.has("pinswg_projectdiscription"), + "DNS should preserve canonical project description field" + ); + + assert.ok( + harbour.has("pinswg_projectdiscription") && + !harbour.has("pinswg_projectdescription"), + "Harbour should preserve misspelled project description variant" + ); + + assert.ok( + dns.has("pinswg_suspensionstartdates") && + !dns.has("pinswg_suspensionstartdate"), + "DNS should preserve plural suspension start field" + ); + + assert.ok( + harbour.has("pinswg_suspensionstartdate") && + !harbour.has("pinswg_suspensionstartdates"), + "Harbour should preserve singular suspension start field variant" + ); + + assert.ok( + electricity.has("modifiedon") && !dns.has("modifiedon"), + "Electricity should preserve current modifiedon overlap while DNS preserves its current omission" + ); +}); + +test("authority and LPA family preserves current field distinctions rather than collapsing them", () => { + const s78Dev = getFieldSet("pinswg_planningappeals78s", "dev-pedw-hc"); + const rows = getFieldSet("pinswg_rows"); + const dns = getFieldSet("pinswg_dnses"); + + assert.ok( + s78Dev.has("_pinswg_localplanningauthority_value") && + !s78Dev.has("_pinswg_associatedlpa_value"), + "S78 should preserve local-planning-authority field variant" + ); + + assert.ok( + rows.has("_pinswg_associatedlpa_value") && + rows.has("pinswg_relevantauthorityname"), + "ROW should preserve overlap of associated LPA and relevant authority fields" + ); + + assert.ok( + dns.has("_pinswg_associatedlpa_value") && + !dns.has("pinswg_relevantauthorityname"), + "DNS should preserve associated LPA without planning-style relevant-authority field" + ); +}); + +test("consultation family is currently specific to SIPS-style profiles", () => { + const sips = getFieldSet("pinswg_sipses"); + const dns = getFieldSet("pinswg_dnses"); + const rows = getFieldSet("pinswg_rows"); + + assertHasFields( + sips, + ["pinswg_consultationopen", "pinswg_consultationclose"], + "SIPS consultation family" + ); + + assert.ok( + !dns.has("pinswg_consultationopen") && + !dns.has("pinswg_consultationclose") && + !rows.has("pinswg_consultationopen") && + !rows.has("pinswg_consultationclose"), + "Consultation fields should preserve their current absence from non-SIPS representative bundles" + ); +}); + +test("minimal-profile family preserves intentionally small bundles and divergence from larger families", () => { + const nonValidation = getFieldSet("pinswg_nonvalidations"); + const ldp = getFieldSet("pinswg_ldps"); + const rows = getFieldSet("pinswg_rows"); + + assert.deepStrictEqual([...ldp], ["modifiedon", "pinswg_name"]); + + assertHasFields( + nonValidation, + [ + "modifiedon", + "_pinswg_appellant_value", + "pinswg_name", + "pinswg_siteaddressline1", + "pinswg_siteaddresspostcode", + "_pinswg_associatedlpa_value", + "statuscode" + ], + "Nonvalidation minimal family" + ); + + assert.ok( + !nonValidation.has("pinswg_questionnaireduedate") && + !nonValidation.has("pinswg_startdate") && + !nonValidation.has("pinswg_applicationacceptedasvalid"), + "Nonvalidation should preserve absence of representation-window and questionnaire family fields" + ); + + assert.ok( + rows.size > nonValidation.size && nonValidation.size > ldp.size, + "Minimal bundles should preserve their intentionally smaller profiles relative to larger planning bundles" + ); +}); + +test("family overlaps and divergences are preserved across representative bundles", () => { + const rows = getFieldSet("pinswg_rows"); + const dns = getFieldSet("pinswg_dnses"); + const sips = getFieldSet("pinswg_sipses"); + const nonValidation = getFieldSet("pinswg_nonvalidations"); + + assert.ok( + rows.has("pinswg_startdate") && rows.has("pinswg_finalcommentsduedate"), + "Planning + representation overlap should be preserved for ROW" + ); + + assert.ok( + dns.has("pinswg_applicationacceptedasvalid") && + dns.has("pinswg_endofrepresentationperiod") && + dns.has("pinswg_projectlocation"), + "Infrastructure + representation overlap should be preserved for DNS" + ); + + assert.ok( + sips.has("pinswg_consultationopen") && + sips.has("pinswg_consultationclose") && + !sips.has("pinswg_finalcommentsduedate"), + "SIPS + consultation overlap and planning-style divergence should be preserved" + ); + + assert.ok( + !nonValidation.has("pinswg_projectlocation") && + !nonValidation.has("pinswg_consultationopen") && + !nonValidation.has("pinswg_questionnaireduedate"), + "Minimal profiles should preserve divergence from infrastructure, consultation, and richer planning families" + ); +}); + +const run = async () => { + let passed = 0; + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 select-query-types field family tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +}