From 18312f50a986d4f996b589f8a71364256e46ada0 Mon Sep 17 00:00:00 2001 From: katie-gardner-AND Date: Fri, 17 Jan 2025 14:11:16 +0000 Subject: [PATCH 01/14] docs: Add key locations to terraform readme --- terraform/container-app/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/terraform/container-app/README.md b/terraform/container-app/README.md index d87137ce9..377edbedb 100644 --- a/terraform/container-app/README.md +++ b/terraform/container-app/README.md @@ -77,6 +77,13 @@ export ARM_CLIENT_ID = export ARM_CLIENT_SECRET = ``` +| Key | Location | +| --------------------- |---------------------------------------------------------------| +| ARM_TENANT_ID | DfE Platform Identity Overview (same for all envs) | +| ARM_SUBSCRIPTION_ID | Managed identities -> s190XXX -> Subscription ID | +| ARM_CLIENT_ID | go to s190-XXX terraform overview, and use the application ID | +| ARM_CLIENT_SECRET | service-principal-secret from the tf-kv | + And sign in to Azure using the Azure CLI, as the Terraform module uses this for part of the infrastructure deployoyment: ``` From 4bf82a59d8ef164392ac00b1fa85e34213b2c93c Mon Sep 17 00:00:00 2001 From: Jim Washbrook Date: Thu, 16 Jan 2025 15:12:21 +0000 Subject: [PATCH 02/14] fix(contentful): Fix various export-processor bugs, add more logging + JSDocs --- contentful/export-processor/.gitignore | 3 +- .../content-types/recommendation-section.js | 91 ++- .../export-processor/content-types/section.js | 22 +- contentful/export-processor/data-mapper.js | 16 +- contentful/export-processor/data-tools.js | 62 +- .../export-processor/errors/error-logger.js | 19 +- .../export-processor/generate-test-suites.js | 44 +- contentful/export-processor/helpers/json.js | 12 + contentful/export-processor/helpers/log.js | 51 ++ .../export-processor/test-suite/test-suite.js | 621 ++++++++++-------- .../export-processor/test-suite/write-csvs.js | 141 ++-- .../user-journey/minimum-path-calculator.js | 21 +- .../user-journey/path-calculator.js | 35 +- .../user-journey/path-part.js | 11 +- .../user-journey/user-journey.js | 12 +- .../write-user-journey-paths.js | 150 +++-- 16 files changed, 793 insertions(+), 518 deletions(-) create mode 100644 contentful/export-processor/helpers/json.js create mode 100644 contentful/export-processor/helpers/log.js diff --git a/contentful/export-processor/.gitignore b/contentful/export-processor/.gitignore index 623522b5b..409e3f8bf 100644 --- a/contentful/export-processor/.gitignore +++ b/contentful/export-processor/.gitignore @@ -6,4 +6,5 @@ output/ inserted-groups.json #Env variables -.env \ No newline at end of file +.env +.env.* \ No newline at end of file diff --git a/contentful/export-processor/content-types/recommendation-section.js b/contentful/export-processor/content-types/recommendation-section.js index 0f859f029..37f335233 100644 --- a/contentful/export-processor/content-types/recommendation-section.js +++ b/contentful/export-processor/content-types/recommendation-section.js @@ -3,43 +3,64 @@ import RecommendationChunk from "./recommendation-chunk.js"; import ErrorLogger from "../errors/error-logger.js"; export default class RecommendationSection { - answers; - chunks; - id; + /** + * @type {Answer[]} + */ + answers; - constructor({ fields, sys }) { - this.id = sys.id; + /** + * @type {RecommendationChunk[]} + */ + chunks; - this.answers = fields.answers?.map((answer) => new Answer(answer)) ?? []; + /** + * @type {string} + */ + id; - this.chunks = fields.chunks?.map((chunk) => new RecommendationChunk(chunk)) ?? []; - this.logErrorIfMissingRelationships("chunks"); - } + /** + * + * @param {{fields: Record, sys: { id: string }}} param0 + */ + constructor({ fields, sys }) { + this.id = sys.id; - logErrorIfMissingRelationships(field) { - const matching = this[field]; - if (!matching || matching.length == 0) { - ErrorLogger.addError({ id: this.id, contentType: "recommendationSection", message: `No ${field} found` }); + this.answers = + fields.answers?.map((answer) => new Answer(answer)) ?? []; + + this.chunks = + fields.chunks?.map((chunk) => new RecommendationChunk(chunk)) ?? []; + + this.logErrorIfMissingRelationships("chunks"); + } + + logErrorIfMissingRelationships(field) { + const matching = this[field]; + if (!matching || matching.length == 0) { + ErrorLogger.addError({ + id: this.id, + contentType: "recommendationSection", + message: `No ${field} found`, + }); + } + } + + getChunksForPath(path) { + const answerIds = path.map((pathPart) => pathPart.answer.id); + + const filteredChunks = this.chunks.filter((chunk) => + chunk.answers.some((answer) => answerIds.includes(answer.id)) + ); + + const uniqueChunks = []; + const seen = new Set(); + + for (const chunk of filteredChunks) { + if (!seen.has(chunk.id)) { + seen.add(chunk.id); + uniqueChunks.push(chunk); + } + } + return uniqueChunks; } - } - - getChunksForPath(path) { - const answerIds = path.map(pathPart => pathPart.answer.id); - - const filteredChunks = this.chunks.filter(chunk => - chunk.answers.some(answer => answerIds.includes(answer.id)) - ); - - const uniqueChunks = []; - const seen = new Set(); - - for (const chunk of filteredChunks) { - const chunkStr = JSON.stringify(chunk); - if (!seen.has(chunkStr)) { - seen.add(chunkStr); - uniqueChunks.push(chunk); - } - } - return uniqueChunks; - } -} \ No newline at end of file +} diff --git a/contentful/export-processor/content-types/section.js b/contentful/export-processor/content-types/section.js index 5dec1eaa8..6b9dee4a1 100644 --- a/contentful/export-processor/content-types/section.js +++ b/contentful/export-processor/content-types/section.js @@ -1,6 +1,7 @@ import { Question } from "./question.js"; import { PathCalculator } from "../user-journey/path-calculator.js"; import { SectionStats } from "#src/user-journey/section-stats"; +import { UserJourney } from "#src/user-journey/user-journey"; /** * @typedef {import("./subtopic-recommendation.js").SubtopicRecommendation} SubtopicRecommendation @@ -14,7 +15,6 @@ import { SectionStats } from "#src/user-journey/section-stats"; * @typedef {import("#src/user-journey/user-journey").UserJourneyMinimalOutput} UserJourneyMinimalOutput */ - /** * @typedef {import("#src/user-journey/user-journey").UserJourney} UserJourney */ @@ -86,10 +86,22 @@ export class Section { * @returns {SectionMinimalOutput} Minimal section info */ toMinimalOutput(writeAllPossiblePaths) { - return { + const recommendationPaths = + this.pathInfo.minimumPathsForRecommendations; + + for (const [maturity, path] of Object.entries(recommendationPaths)) { + recommendationPaths[maturity] = path.map((part) => + part.toMinimalOutput() + ); + } + + const result = { section: this.name, allPathsStats: this.stats.pathsPerMaturity, - minimumQuestionPaths: this.pathInfo.minimumPathsToNavigateQuestions, + minimumQuestionPaths: + this.pathInfo.minimumPathsToNavigateQuestions.map((path) => + path.map((part) => part.toMinimalOutput()) + ), minimumRecommendationPaths: this.pathInfo.minimumPathsForRecommendations, pathsForAnswers: this.pathInfo.pathsForAllPossibleAnswers.map( @@ -99,6 +111,8 @@ export class Section { ? this.pathInfo.paths.map((path) => path.toMinimalOutput()) : undefined, }; + + return result; } } @@ -110,4 +124,4 @@ export class Section { * @property {Record} minimumRecommendationPaths Shortest amount of paths possible to get every possible recommendation chunk * @property {UserJourneyMinimalOutput[]} pathsForAnswers Shortest amount of paths possible to navigate through every answer * @property {(UserJourneyMinimalOutput[] | undefined)} allPossiblePaths All possible paths - */ \ No newline at end of file + */ diff --git a/contentful/export-processor/data-mapper.js b/contentful/export-processor/data-mapper.js index 8ea346d20..27dc46ccf 100644 --- a/contentful/export-processor/data-mapper.js +++ b/contentful/export-processor/data-mapper.js @@ -1,6 +1,7 @@ import ContentType from "./content-types/content-type.js"; import SubtopicRecommendation from "./content-types/subtopic-recommendation.js"; import ErrorLogger from "./errors/error-logger.js"; +import { log } from "#src/helpers/log"; /** * @typedef {import("./content-types/section.js").Section} Section @@ -20,7 +21,7 @@ export default class DataMapper { /** * Get the mapped sections - * @returns {IterableIterator
} Iterator for mapped sections + * @returns {Section[]} Iterator for mapped sections */ get mappedSections() { if (!this._alreadyMappedSections) @@ -130,9 +131,18 @@ export default class DataMapper { } for (const [, subtopicRecommendation] of subtopicRecommendations) { - const mapped = new SubtopicRecommendation(subtopicRecommendation); + try { + const mapped = new SubtopicRecommendation( + subtopicRecommendation + ); - yield mapped.subtopic; + yield mapped.subtopic; + } catch (e) { + console.error( + `Error trying to created SubtopicRecommendation: ${e}`, + subtopicRecommendation + ); + } } } diff --git a/contentful/export-processor/data-tools.js b/contentful/export-processor/data-tools.js index b77a6fc18..a8ca3ba39 100644 --- a/contentful/export-processor/data-tools.js +++ b/contentful/export-processor/data-tools.js @@ -6,32 +6,40 @@ import WriteUserJourneyPaths from "./write-user-journey-paths.js"; import { options } from "./options.js"; async function processContentfulData(args) { - const contentfulData = await ExportContentfulData({ ...args }); - - if (!args.generateTestSuites && !args.exportUserJourneyPaths && !args.exportUserJourneyPaths) { - console.log(`No options set for using data - ending run`); - return; - } - - if (!contentfulData.entries || !contentfulData.contentTypes) { - console.error('Missing entries or content types'); - return; - } - - const dataMapper = new DataMapper(contentfulData); - - const outputDir = args.outputDir; - - if (args.generateTestSuites) { - GenerateTestSuites({ dataMapper, outputDir }); - } - - if (!!args.exportUserJourneyPaths) { - WriteUserJourneyPaths({ dataMapper, outputDir, saveAllJourneys: args.saveAllJourneys }); - } - - ErrorLogger.outputDir = outputDir; - ErrorLogger.writeErrorsToFile(); + const contentfulData = await ExportContentfulData({ ...args }); + + if ( + !args.generateTestSuites && + !args.exportUserJourneyPaths && + !args.exportUserJourneyPaths + ) { + console.log(`No options set for using data - ending run`); + return; + } + + if (!contentfulData.entries || !contentfulData.contentTypes) { + console.error("Missing entries or content types"); + return; + } + + const dataMapper = new DataMapper(contentfulData); + + const outputDir = args.outputDir; + + if (args.generateTestSuites) { + GenerateTestSuites({ dataMapper, outputDir }); + } + + if (args.exportUserJourneyPaths) { + WriteUserJourneyPaths({ + dataMapper, + outputDir, + saveAllJourneys: args.saveAllJourneys, + }); + } + + ErrorLogger.outputDir = outputDir; + ErrorLogger.writeErrorsToFile(); } -await processContentfulData(options); \ No newline at end of file +await processContentfulData(options); diff --git a/contentful/export-processor/errors/error-logger.js b/contentful/export-processor/errors/error-logger.js index e1973b90d..273f4f325 100644 --- a/contentful/export-processor/errors/error-logger.js +++ b/contentful/export-processor/errors/error-logger.js @@ -2,7 +2,7 @@ import ContentError from "./content-error.js"; import fs from "fs"; /** - * @typedef {{id: string, contentType: string, message: string}} Error + * @typedef {{id: string, contentType: string, message: string}} Error */ class ErrorLogger { @@ -10,8 +10,8 @@ class ErrorLogger { outputDir = ""; /** - * - * @param {Error} error + * + * @param {Error} error */ addError({ id, contentType, message }) { const error = new ContentError({ id, contentType, message }); @@ -22,6 +22,8 @@ class ErrorLogger { } writeErrorsToFile(filePath = "content-errors.md") { + console.log(`Writing errors to file ${filePath}`); + const groupedByContentType = this.groupBy(this.errors, "contentType"); const errors = @@ -33,12 +35,13 @@ class ErrorLogger { .join("\n\n"); fs.writeFileSync(this.outputDir + filePath, errors); + console.log(`Wrote errors to file ${filePath}`); } /** - * - * @param {string} contentType - * @param {Error[]} errors + * + * @param {string} contentType + * @param {Error[]} errors * @returns {string} Error messages for a particular content type formatted as string */ errorMessagesForContentType(contentType, errors) { @@ -73,6 +76,4 @@ const errorLogger = new ErrorLogger(); export default errorLogger; -export { - ErrorLogger as ErrorLoggerClass -} \ No newline at end of file +export { ErrorLogger as ErrorLoggerClass }; diff --git a/contentful/export-processor/generate-test-suites.js b/contentful/export-processor/generate-test-suites.js index c899b047a..043a34883 100644 --- a/contentful/export-processor/generate-test-suites.js +++ b/contentful/export-processor/generate-test-suites.js @@ -2,29 +2,41 @@ import "dotenv/config"; import TestSuite from "#src/test-suite/test-suite"; import writeTestSuites from "./test-suite/write-csvs.js"; import StaticPageTests from "./test-suite/static-page-tests.js"; +import { log } from "./helpers/log.js"; /** * @param {object} args - * @param {DataMapper} args.dataMapper - created and initialised DataMapper + * @param {import("./data-mapper.js").default} args.dataMapper - created and initialised DataMapper * @param {string} args.outputDir - directory to write journey paths to */ export default function generateTestSuites({ dataMapper, outputDir }) { - let index = 1; - const staticPageTests = new StaticPageTests(); + let index = 1; + const staticPageTests = new StaticPageTests(); - const testSuites = Object.values(dataMapper.mappedSections) - .filter(section => !!section) - .map((section) => { - const testSuite = new TestSuite({ - subtopic: section, - testReferenceIndex: index, - }); + log("Generating test suites"); - index = testSuite.testReferenceIndex; + const testSuites = Object.values(dataMapper.mappedSections) + .filter((section) => !!section) + .map((section) => { + log(`Generating test suite for ${section.name}`); - return testSuite; - }); + const testSuite = new TestSuite({ + subtopic: section, + testReferenceIndex: index, + }); + index = testSuite.testReferenceIndex; - testSuites.push(staticPageTests); - writeTestSuites({ testSuites, outputDir }); -} \ No newline at end of file + log(`Test suite for ${section.name} generated`, { + addSeperator: true, + }); + + return testSuite; + }); + + log("Generated test suites"); + + testSuites.push(staticPageTests); + writeTestSuites({ testSuites, outputDir }); + + log("Wrote test suite CSVs", { addSeperator: true }); +} diff --git a/contentful/export-processor/helpers/json.js b/contentful/export-processor/helpers/json.js new file mode 100644 index 000000000..e9e9e62c1 --- /dev/null +++ b/contentful/export-processor/helpers/json.js @@ -0,0 +1,12 @@ +import { stringify as flattenedStringify } from "flatted"; + +const stringify = (obj) => { + try { + return JSON.stringify(obj); + } catch (e) { + console.error(`Error stringifying ${obj}. Will try flatted`, e); + return flattenedStringify(obj); + } +}; + +export { stringify }; diff --git a/contentful/export-processor/helpers/log.js b/contentful/export-processor/helpers/log.js new file mode 100644 index 000000000..9de391983 --- /dev/null +++ b/contentful/export-processor/helpers/log.js @@ -0,0 +1,51 @@ +const defaultLogOptions = { + addBlankLine: true, + addSeperator: false, + seperator: "============", + level: "INFO", +}; + +/** + * Logs message to console with formatting + * @param {string} message Message to log + * @param {{addBlankLine: boolean, addSeperator: boolean, seperator: string, level: "INFO" | "WARNING" | "ERROR"}} options + */ +const logMessage = (message, options = defaultLogOptions) => { + const { addBlankLine, addSeperator, seperator, level } = { + ...defaultLogOptions, + ...options, + }; + + const logger = level == "ERROR" ? console.error : console.log; + + const formattedMessage = formatMessage( + level ?? defaultLogOptions.level, + message + ); + logger(formattedMessage); + + if (addBlankLine) { + console.log(""); + } + + if (addSeperator) { + console.log(seperator); + console.log(""); + } +}; + +/** + * + * @param {string} level + * @param {string} message + * @returns + */ +const formatMessage = (level, message) => + `[${getTimeString()}] ${level} - ${message}`; + +const getTimeString = () => { + var now = new Date(); + return `${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}`; +}; + +export { logMessage as log }; diff --git a/contentful/export-processor/test-suite/test-suite.js b/contentful/export-processor/test-suite/test-suite.js index a70ca2b4e..739aef370 100644 --- a/contentful/export-processor/test-suite/test-suite.js +++ b/contentful/export-processor/test-suite/test-suite.js @@ -1,132 +1,138 @@ import TestSuiteRow from "./test-suite-row.js"; import AppendixRow from "./appendix-row.js"; +import { log } from "#src/helpers/log"; const ADO_TAG = "Functional.js"; export default class TestSuiteForSubTopic { - subtopic; - testReferenceIndex; - - testCaseGenerators = [ - this.generateCanNavigateToSubtopic.bind(this), - this.generateCanSaveAnswers.bind(this), - this.generateAttemptsSaveWithoutAnswer.bind(this), - this.generateSavesPartialAnswers.bind(this), - this.generateReturnToPartialAnswers.bind(this), - this.generateManualNavToURLQuestion.bind(this), - this.generateCompleteJourney.bind(this), - this.generateUpdateResubmitResponses.bind(this), - this.generateReceivesLowScoringLogic.bind(this), - this.generateReceivesHighScoringLogic.bind(this), - this.generateReceivesMidScoringLogic.bind(this), - this.generateChangeAnswersCheckYourAnswers.bind(this), - this.generateReturnToSelfAssessment.bind(this), - this.generateUseBackButton.bind(this), - this.generateSharePage.bind(this), - this.generateNavigateLowMatChunks.bind(this), - this.generateNavigateLowMatCSLinks.bind(this), - ]; - - testCases = []; - appendix = []; - subtopicName; - - constructor({ subtopic, testReferenceIndex }) { - this.subtopic = subtopic; - this.subtopicName = this.subtopic.name.trim(); - this.testReferenceIndex = testReferenceIndex; - - this.testCases = this.testCaseGenerators.map((generator) => generator()).filter((testCase) => !!testCase); - } - - /** - * - * @param {*} testScenario - * @param {*} testSteps - * @param {*} expectedOutcome - * @param {AppendixRow | undefined} appendix - * @returns - */ - createRow(testScenario, testSteps, expectedOutcome, appendix) { - const row = new TestSuiteRow({ - testReference: `Access_${this.testReferenceIndex++}`, - adoTag: ADO_TAG, - subtopic: this.subtopicName, - testScenario: testScenario, - preConditions: "User is signed into the DfE Sign in service", - testSteps: testSteps, - expectedOutcome: expectedOutcome, - appendixRef: appendix?.reference - }); - - if (appendix) { - this.appendix.push(appendix); + /** + * @type {import ("../content-types/section.js").Section} + */ + subtopic; + testReferenceIndex; + + testCaseGenerators = [ + this.generateCanNavigateToSubtopic.bind(this), + this.generateCanSaveAnswers.bind(this), + this.generateAttemptsSaveWithoutAnswer.bind(this), + this.generateSavesPartialAnswers.bind(this), + this.generateReturnToPartialAnswers.bind(this), + this.generateManualNavToURLQuestion.bind(this), + this.generateCompleteJourney.bind(this), + this.generateUpdateResubmitResponses.bind(this), + this.generateReceivesLowScoringLogic.bind(this), + this.generateReceivesHighScoringLogic.bind(this), + this.generateReceivesMidScoringLogic.bind(this), + this.generateChangeAnswersCheckYourAnswers.bind(this), + this.generateReturnToSelfAssessment.bind(this), + this.generateUseBackButton.bind(this), + this.generateSharePage.bind(this), + this.generateNavigateLowMatChunks.bind(this), + this.generateNavigateLowMatCSLinks.bind(this), + ]; + + testCases = []; + appendix = []; + subtopicName; + + constructor({ subtopic, testReferenceIndex }) { + this.subtopic = subtopic; + this.subtopicName = this.subtopic.name.trim(); + this.testReferenceIndex = testReferenceIndex; + + this.testCases = this.testCaseGenerators + .map((generator) => generator()) + .filter((testCase) => !!testCase); } - return row; - } - - generateCanNavigateToSubtopic() { - const testScenario = `User can navigate to the ${this.subtopicName} subtopic from the self-assessment page`; - const testSteps = `1 - Navigate to ${this.subtopicName} subtopic`; - const expectedOutcome = `User sees ${this.subtopicName} interstitial page`; - return this.createRow(testScenario, testSteps, expectedOutcome); - } - - generateCanSaveAnswers() { - const testScenario = `User can save answers to questions for ${this.subtopicName} subtopic`; - const testSteps = `1 - Navigate to ${this.subtopicName} subtopic 2 - Answer and save each question presented 3 - Compare the answers presented on 'Check your answers' page to your answers`; - const expectedOutcome = `Answers match. Can save and continue.`; - return this.createRow(testScenario, testSteps, expectedOutcome); - } - - generateAttemptsSaveWithoutAnswer() { - const testScenario = `User attempts to save and continue without selecting an answer`; - const testSteps = `1 - Navigate to ${this.subtopicName} subtopic + /** + * + * @param {*} testScenario + * @param {*} testSteps + * @param {*} expectedOutcome + * @param {AppendixRow | undefined} appendix + * @returns + */ + createRow(testScenario, testSteps, expectedOutcome, appendix) { + const row = new TestSuiteRow({ + testReference: `Access_${this.testReferenceIndex++}`, + adoTag: ADO_TAG, + subtopic: this.subtopicName, + testScenario: testScenario, + preConditions: "User is signed into the DfE Sign in service", + testSteps: testSteps, + expectedOutcome: expectedOutcome, + appendixRef: appendix?.reference, + }); + + if (appendix) { + this.appendix.push(appendix); + } + + return row; + } + + generateCanNavigateToSubtopic() { + const testScenario = `User can navigate to the ${this.subtopicName} subtopic from the self-assessment page`; + const testSteps = `1 - Navigate to ${this.subtopicName} subtopic`; + const expectedOutcome = `User sees ${this.subtopicName} interstitial page`; + return this.createRow(testScenario, testSteps, expectedOutcome); + } + + generateCanSaveAnswers() { + const testScenario = `User can save answers to questions for ${this.subtopicName} subtopic`; + const testSteps = `1 - Navigate to ${this.subtopicName} subtopic 2 - Answer and save each question presented 3 - Compare the answers presented on 'Check your answers' page to your answers`; + const expectedOutcome = `Answers match. Can save and continue.`; + return this.createRow(testScenario, testSteps, expectedOutcome); + } + + generateAttemptsSaveWithoutAnswer() { + const testScenario = `User attempts to save and continue without selecting an answer`; + const testSteps = `1 - Navigate to ${this.subtopicName} subtopic 2 - Do not answer question 3 - Select 'save and continue'`; - const expectedOutcome = `User sees modal that states 'There is a problem. You must select an answer to continue'.`; - return this.createRow(testScenario, testSteps, expectedOutcome); - } + const expectedOutcome = `User sees modal that states 'There is a problem. You must select an answer to continue'.`; + return this.createRow(testScenario, testSteps, expectedOutcome); + } - generateSavesPartialAnswers() { - const testScenario = `User saves partially completed ${this.subtopicName} subtopic answers`; - const testSteps = `1 - Navigate to ${this.subtopicName} subtopic + generateSavesPartialAnswers() { + const testScenario = `User saves partially completed ${this.subtopicName} subtopic answers`; + const testSteps = `1 - Navigate to ${this.subtopicName} subtopic 2 - Select first answer 3 - Select back until reaching self assessment page`; - const expectedOutcome = `User is returned to self assessment page. Broadband connection status shows 'In progress'.`; - return this.createRow(testScenario, testSteps, expectedOutcome); - } + const expectedOutcome = `User is returned to self assessment page. Broadband connection status shows 'In progress'.`; + return this.createRow(testScenario, testSteps, expectedOutcome); + } - generateReturnToPartialAnswers() { - const testScenario = `User can return to a partially completed ${this.subtopicName} question set and pick up where they left off`; - const testSteps = `1 - Navigate to ${this.subtopicName} subtopic + generateReturnToPartialAnswers() { + const testScenario = `User can return to a partially completed ${this.subtopicName} question set and pick up where they left off`; + const testSteps = `1 - Navigate to ${this.subtopicName} subtopic 2 - Navigate through the interstitial page`; - const expectedOutcome = `User is returned to the first unanswered question. User can continue through to reach a recommendation.`; - return this.createRow(testScenario, testSteps, expectedOutcome); - } + const expectedOutcome = `User is returned to the first unanswered question. User can continue through to reach a recommendation.`; + return this.createRow(testScenario, testSteps, expectedOutcome); + } - generateManualNavToURLQuestion() { - const testScenario = `User manually goes to a question ahead of their journey for a subtopic via URL`; - const testSteps = `1 - Navigate to ${this.subtopicName} subtopic + generateManualNavToURLQuestion() { + const testScenario = `User manually goes to a question ahead of their journey for a subtopic via URL`; + const testSteps = `1 - Navigate to ${this.subtopicName} subtopic 2 - Navigate ahead of journey via URL`; - const expectedOutcome = `User journey shows in progress.`; - return this.createRow(testScenario, testSteps, expectedOutcome); - } + const expectedOutcome = `User journey shows in progress.`; + return this.createRow(testScenario, testSteps, expectedOutcome); + } - generateCompleteJourney() { - const testScenario = `User can complete journey through all questions in ${this.subtopicName} topic`; - const testSteps = `1 - Navigate to ${this.subtopicName} subtopic + generateCompleteJourney() { + const testScenario = `User can complete journey through all questions in ${this.subtopicName} topic`; + const testSteps = `1 - Navigate to ${this.subtopicName} subtopic 2 - Navigate through the interstitial page 3 - Navigate through questions/check answers 4 - Save and continue`; - const expectedOutcome = `User returns to self-assessment page and sees success modal.`; - return this.createRow(testScenario, testSteps, expectedOutcome); - } + const expectedOutcome = `User returns to self-assessment page and sees success modal.`; + return this.createRow(testScenario, testSteps, expectedOutcome); + } - generateSharePage() { - const testScenario = `User navigates to the share/download page`; - const testSteps = `1 - Navigate to ${this.subtopicName} subtopic + generateSharePage() { + const testScenario = `User navigates to the share/download page`; + const testSteps = `1 - Navigate to ${this.subtopicName} subtopic 2 - Navigate through the interstitial page 3 - Navigate through questions all questions and the check answers page 4 - Save and continue @@ -135,213 +141,262 @@ export default class TestSuiteForSubTopic { 7 - Verify that you are redirected to the share/download page, 8 - Verify that the print button is present and works as expected`; + const expectedOutcome = `User can access the share/download page.`; + return this.createRow(testScenario, testSteps, expectedOutcome); + } - const expectedOutcome = `User can access the share/download page.`; - return this.createRow(testScenario, testSteps, expectedOutcome); - } - - generateUpdateResubmitResponses() { - const testScenario = `A different user can update and re-submit their responses`; - const testSteps = `1 - Navigate to the ${this.subtopicName} subtopic + generateUpdateResubmitResponses() { + const testScenario = `A different user can update and re-submit their responses`; + const testSteps = `1 - Navigate to the ${this.subtopicName} subtopic 2 - Navigate through the interstitial page 3 - Navigate through questions/check answers 4 - Save and continue`; - const expectedOutcome = `Recommendations are updated.`; - return this.createRow(testScenario, testSteps, expectedOutcome); - } - - generateReceivesLowScoringLogic() { - return this.generateTestForMaturity("Low"); - } - - generateReceivesHighScoringLogic() { - return this.generateTestForMaturity("High"); - } - - generateReceivesMidScoringLogic() { - return this.generateTestForMaturity("Medium"); - } - - generateTestForMaturity(maturity) { - const pathForLow = this.getPathForMaturity(maturity) - if (!pathForLow) { - console.error(`No '${maturity}' maturity journey for ${this.subtopicName}`); - return; + const expectedOutcome = `Recommendations are updated.`; + return this.createRow(testScenario, testSteps, expectedOutcome); } - const testScenario = `User receives recommendation for ${maturity} maturity`; - - const testSteps = [`1 - Navigate to the ${this.subtopicName} subtopic`, `2 - Navigate through the interstitial page`, - ...pathForLow.map((pathPart, index) => `3.${index + 1} - Choose answer '${pathPart.answer.text}' for question '${pathPart.question.text}'`), - `4 - Save and continue`, `5 - View ${this.subtopicName} recommendation`].join("\n").replace(",", ""); - - const content = this.subtopic.recommendation.getContentForMaturityAndPath({ maturity, path: pathForLow }); - + generateReceivesLowScoringLogic() { + return this.generateTestForMaturity("Low"); + } - const expectedOutcome = `User taken to '${maturity}' recommendation page with slug '${content.intro.slug}' for ${this.subtopicName}.`; + generateReceivesHighScoringLogic() { + return this.generateTestForMaturity("High"); + } - const intro = { - header: content.intro.header, - content: content.intro.content - }; + generateReceivesMidScoringLogic() { + return this.generateTestForMaturity("Medium"); + } - const chunkContents = content.chunks.map(chunk => ({ - header: chunk.header, - title: chunk.title, - content: chunk.content - })); + generateTestForMaturity(maturity) { + const pathForLow = this.getPathForMaturity(maturity); + if (!pathForLow) { + log(`No '${maturity}' maturity journey for ${this.subtopicName}`, { + level: "ERROR", + }); + return; + } - const asCsvContent = ` + const testScenario = `User receives recommendation for ${maturity} maturity`; + + const testSteps = [ + `1 - Navigate to the ${this.subtopicName} subtopic`, + `2 - Navigate through the interstitial page`, + ...pathForLow.map( + (pathPart, index) => + `3.${index + 1} - Choose answer '${ + pathPart.answer.text + }' for question '${pathPart.question.text}'` + ), + `4 - Save and continue`, + `5 - View ${this.subtopicName} recommendation`, + ] + .join("\n") + .replace(",", ""); + + const content = + this.subtopic.recommendation.getContentForMaturityAndPath({ + maturity, + path: pathForLow, + }); + + const expectedOutcome = `User taken to '${maturity}' recommendation page with slug '${content.intro.slug}' for ${this.subtopicName}.`; + + const intro = { + header: content.intro.header, + content: content.intro.content, + }; + + const chunkContents = content.chunks.map((chunk) => ({ + header: chunk.header, + title: chunk.title, + content: chunk.content, + })); + + const asCsvContent = ` Expected intro: Header: '${intro.header}' Content: '${intro.content}' - ${chunkContents.map((chunk, index) => - `Accordion section ${index + 2}: + ${chunkContents + .map( + (chunk, index) => + `Accordion section ${index + 2}: Header: '${chunk.header}' Title: '${chunk.title}' Content: '${chunk.content}' - `).join("\n")}`; - - const appendixRow = new AppendixRow({ reference: `Access_${this.testReferenceIndex}_Appendix`, content: asCsvContent }); - - return this.createRow(testScenario, testSteps, expectedOutcome, appendixRow); - } + ` + ) + .join("\n")}`; + + const appendixRow = new AppendixRow({ + reference: `Access_${this.testReferenceIndex}_Appendix`, + content: asCsvContent, + }); + + return this.createRow( + testScenario, + testSteps, + expectedOutcome, + appendixRow + ); + } - generateChangeAnswersCheckYourAnswers() { - const testScenario = `User can change answers via the 'Check your answers' page`; - const testSteps = - `1 - Navigate to the ${this.subtopicName} subtopic + generateChangeAnswersCheckYourAnswers() { + const testScenario = `User can change answers via the 'Check your answers' page`; + const testSteps = `1 - Navigate to the ${this.subtopicName} subtopic 2 - Navigate through the interstitial page 3 - Navigate to the change button on 'Check your answers' page 4 - Change answer`; - const expectedOutcome = `Users answers update to match new answers.`; - return this.createRow(testScenario, testSteps, expectedOutcome); - } + const expectedOutcome = `Users answers update to match new answers.`; + return this.createRow(testScenario, testSteps, expectedOutcome); + } - generateReturnToSelfAssessment() { - const testScenario = `User returns to self - assessment screen during question routing`; - const testSteps = - `1 - Navigate to the ${this.subtopicName} subtopic + generateReturnToSelfAssessment() { + const testScenario = `User returns to self - assessment screen during question routing`; + const testSteps = `1 - Navigate to the ${this.subtopicName} subtopic 2 - Navigate through the interstitial page 3 - Answer first question, save and continue 4 - User clicks PTFYS header`; - const expectedOutcome = `User returned to self - assessment page.${this.subtopicName} subtopic shows 'In progress'.`; - return this.createRow(testScenario, testSteps, expectedOutcome); - } - - generateUseBackButton() { - const testScenario = `User uses back button to navigate back through questions to self assesment page`; - const testSteps = - `1 - Navigate to the ${this.subtopicName} subtopic + const expectedOutcome = `User returned to self - assessment page.${this.subtopicName} subtopic shows 'In progress'.`; + return this.createRow(testScenario, testSteps, expectedOutcome); + } + + generateUseBackButton() { + const testScenario = `User uses back button to navigate back through questions to self assesment page`; + const testSteps = `1 - Navigate to the ${this.subtopicName} subtopic 2 - Navigate through the interstitial page 3 - Answer first question, save and continue 4 - Use back button to return to first queston 5 - use back button again to return to self assesment page`; - const expectedOutcome = `User returned to self - assessment page.${this.subtopicName} subtopic shows 'In progress'.`; - return this.createRow(testScenario, testSteps, expectedOutcome); - } - - generateNavigateForRecommendationChunks(maturity) { - const pathForMaturity = this.getPathForMaturity(maturity) - - if (!pathForMaturity) { - console.error(`No '${maturity}' maturity journey for ${this.subtopicName}`); - return; - } - - const answerIds = this.getAnswerIds(pathForMaturity); - const chunks = this.subtopic.recommendation.section.chunks; - - const filteredChunks = this.getAnswerBasedChunks(chunks, answerIds) - - const testScenario = `User can navigate between recommendation chunks`; - const testSteps = [ - `1 - Navigate to the ${this.subtopicName} subtopic`, - `2 - Navigate through the interstitial page`, - ...pathForMaturity.map((pathPart, index) => `3.${index + 1} - Choose answer '${pathPart.answer.text}' for question '${pathPart.question.text}'`), - `4 - Save and continue`, - `5 - View ${this.subtopicName} recommendation`, - `6 - Using the navigation bar and pagination buttons, navigate between the different recommendation chunks:`, - ...filteredChunks.map((chunk, index) => `6.${index + 1} - Navigate to the '${chunk.header}' chunk`) - ].join("\n").replace(",", ""); - - const expectedOutcome = `User is able to view each recommendation chunk.`; - return this.createRow(testScenario, testSteps, expectedOutcome); - } - - generateCSLinkChunks(maturity) { - const pathForMaturity = this.getPathForMaturity(maturity); - - if (!pathForMaturity) { - console.error(`No '${maturity}' maturity journey for ${this.subtopicName}`); - return; + const expectedOutcome = `User returned to self - assessment page.${this.subtopicName} subtopic shows 'In progress'.`; + return this.createRow(testScenario, testSteps, expectedOutcome); } - - const answerIds = this.getAnswerIds(pathForMaturity); - const chunks = this.subtopic.recommendation.section.chunks; - - const filteredChunks = this.getCSLinkChunks(chunks, answerIds) - - if (filteredChunks.length === 0) { - console.log(`No chunks with CSLink for maturity '${maturity}' in ${this.subtopicName}.`); - return; + + generateNavigateForRecommendationChunks(maturity) { + const pathForMaturity = this.getPathForMaturity(maturity); + + if (!pathForMaturity) { + log(`No '${maturity}' maturity journey for ${this.subtopicName}`, { + level: "ERROR", + }); + return; + } + + const answerIds = this.getAnswerIds(pathForMaturity); + const chunks = this.subtopic.recommendation.section.chunks; + + const filteredChunks = this.getAnswerBasedChunks(chunks, answerIds); + + const testScenario = `User can navigate between recommendation chunks`; + const testSteps = [ + `1 - Navigate to the ${this.subtopicName} subtopic`, + `2 - Navigate through the interstitial page`, + ...pathForMaturity.map( + (pathPart, index) => + `3.${index + 1} - Choose answer '${ + pathPart.answer.text + }' for question '${pathPart.question.text}'` + ), + `4 - Save and continue`, + `5 - View ${this.subtopicName} recommendation`, + `6 - Using the navigation bar and pagination buttons, navigate between the different recommendation chunks:`, + ...filteredChunks.map( + (chunk, index) => + `6.${index + 1} - Navigate to the '${chunk.header}' chunk` + ), + ] + .join("\n") + .replace(",", ""); + + const expectedOutcome = `User is able to view each recommendation chunk.`; + return this.createRow(testScenario, testSteps, expectedOutcome); } - - const testScenario = `User can navigate between recommendation chunks with C&S links and to C&S content`; - const testSteps = [ - `1 - Navigate to the ${this.subtopicName} subtopic`, - `2 - Navigate through the interstitial page`, - ...pathForMaturity.map((pathPart, index) => `3.${index} - Choose answer '${pathPart.answer.text}' for question '${pathPart.question.text}'`), - `4 - Save and continue`, - `5 - View ${this.subtopicName} recommendation`, - `6 - Using the navigation bar and pagination buttons, navigate between the different recommendation chunks with unique csLink:`, - ...filteredChunks.flatMap((chunk, index) => [ - `6.${index} - Navigate to the '${chunk.header}' chunk`, - `6.${index} - Click the link with text '${chunk.csLink.linkText}'`, - `6.${index} - Verify the C&S content renders correctly`, - `6.${index} - Go back to the recommendation chunks using the back button` - ]) - ].join("\n").replace(",", ""); - - const expectedOutcome = `User is able to view the recommendation chunk with C&S link and verify the C&S content.`; - return this.createRow(testScenario, testSteps, expectedOutcome); - } - - generateNavigateLowMatChunks() { - return this.generateNavigateForRecommendationChunks('Low'); - } - - generateNavigateLowMatCSLinks() { - return this.generateCSLinkChunks('Low'); - } - - getPathForMaturity(maturity) { - return this.subtopic.minimumPathsForRecommendations[maturity]; - } - - getAnswerIds(pathForMaturity) { - return pathForMaturity.map(q => q.answer.id); - } - - getAnswerBasedChunks(chunks, answerIds) { - return chunks.filter(chunk => - chunk.answers.some(answer => answerIds.includes(answer.id)) - ); - } - - getCSLinkChunks(chunks, answerIds) { - const uniqueCsLinks = new Set(); - return chunks.filter(chunk => { - if (chunk.csLink && chunk.answers.some(answer => answerIds.includes(answer.id))) { - const linkText = chunk.csLink.linkText; - if (!uniqueCsLinks.has(linkText)) { - uniqueCsLinks.add(linkText); - return true; + + generateCSLinkChunks(maturity) { + const pathForMaturity = this.getPathForMaturity(maturity); + + if (!pathForMaturity) { + log(`No '${maturity}' maturity journey for ${this.subtopicName}`, { + level: "ERROR", + }); + return; + } + + const answerIds = this.getAnswerIds(pathForMaturity); + const chunks = this.subtopic.recommendation.section.chunks; + + const filteredChunks = this.getCSLinkChunks(chunks, answerIds); + + if (filteredChunks.length === 0) { + log( + `No chunks with CSLink for maturity '${maturity}' in ${this.subtopicName}.`, + { level: "WARNING" } + ); + return; } - } - return false; - }); - } + + const testScenario = `User can navigate between recommendation chunks with C&S links and to C&S content`; + const testSteps = [ + `1 - Navigate to the ${this.subtopicName} subtopic`, + `2 - Navigate through the interstitial page`, + ...pathForMaturity.map( + (pathPart, index) => + `3.${index} - Choose answer '${pathPart.answer.text}' for question '${pathPart.question.text}'` + ), + `4 - Save and continue`, + `5 - View ${this.subtopicName} recommendation`, + `6 - Using the navigation bar and pagination buttons, navigate between the different recommendation chunks with unique csLink:`, + ...filteredChunks.flatMap((chunk, index) => [ + `6.${index} - Navigate to the '${chunk.header}' chunk`, + `6.${index} - Click the link with text '${chunk.csLink.linkText}'`, + `6.${index} - Verify the C&S content renders correctly`, + `6.${index} - Go back to the recommendation chunks using the back button`, + ]), + ] + .join("\n") + .replace(",", ""); + + const expectedOutcome = `User is able to view the recommendation chunk with C&S link and verify the C&S content.`; + return this.createRow(testScenario, testSteps, expectedOutcome); + } + + generateNavigateLowMatChunks() { + return this.generateNavigateForRecommendationChunks("Low"); + } + + generateNavigateLowMatCSLinks() { + return this.generateCSLinkChunks("Low"); + } + + getPathForMaturity(maturity) { + return this.subtopic.pathInfo.minimumPathsForRecommendations[maturity]; + } + + getAnswerIds(pathForMaturity) { + return pathForMaturity.map((q) => q.answer.id); + } + + getAnswerBasedChunks(chunks, answerIds) { + return chunks.filter((chunk) => + chunk.answers.some((answer) => answerIds.includes(answer.id)) + ); + } + + getCSLinkChunks(chunks, answerIds) { + const uniqueCsLinks = new Set(); + return chunks.filter((chunk) => { + if ( + chunk.csLink && + chunk.answers.some((answer) => answerIds.includes(answer.id)) + ) { + const linkText = chunk.csLink.linkText; + if (!uniqueCsLinks.has(linkText)) { + uniqueCsLinks.add(linkText); + return true; + } + } + return false; + }); + } } diff --git a/contentful/export-processor/test-suite/write-csvs.js b/contentful/export-processor/test-suite/write-csvs.js index 587b4fe11..adc915d3b 100644 --- a/contentful/export-processor/test-suite/write-csvs.js +++ b/contentful/export-processor/test-suite/write-csvs.js @@ -2,97 +2,100 @@ import fs from "fs"; import path from "path"; // eslint-disable-next-line no-unused-vars import TestSuite from "./test-suite.js"; +import { log } from "#src/helpers/log"; const mainSheetColumns = [ - { testReference: "Test Reference" }, - { appendixRef: "Appendix Reference" }, - { adoTag: "ADO Tag" }, - { subtopic: "Sub-topic" }, - { testScenario: "Test Scenario" }, - { preConditions: "Pre-conditions" }, - { testSteps: "Test Steps" }, - { expectedOutcome: "Expected Outcome" }, - { testApproved: "Test Approved" }, + { testReference: "Test Reference" }, + { appendixRef: "Appendix Reference" }, + { adoTag: "ADO Tag" }, + { subtopic: "Sub-topic" }, + { testScenario: "Test Scenario" }, + { preConditions: "Pre-conditions" }, + { testSteps: "Test Steps" }, + { expectedOutcome: "Expected Outcome" }, + { testApproved: "Test Approved" }, ]; -const appendixColumns = [ - { reference: "Reference" }, - { content: "Content" }, -]; +const appendixColumns = [{ reference: "Reference" }, { content: "Content" }]; const createCsvInfo = (columns) => { - const keys = []; - const headers = []; - - columns.forEach((columnObj) => { - const [key, header] = Object.entries(columnObj)[0]; - keys.push(key); - headers.push(header); - }); - - return { - headers: headers, - keys: keys - }; + const keys = []; + const headers = []; + + columns.forEach((columnObj) => { + const [key, header] = Object.entries(columnObj)[0]; + keys.push(key); + headers.push(header); + }); + + return { + headers: headers, + keys: keys, + }; }; const createCsvs = () => { - const mainCsv = createCsvInfo(mainSheetColumns); - const appendixCsv = createCsvInfo(appendixColumns); + const mainCsv = createCsvInfo(mainSheetColumns); + const appendixCsv = createCsvInfo(appendixColumns); - return { mainCsv, appendixCsv }; + return { mainCsv, appendixCsv }; }; /** - * - * @param {*} param0 + * + * @param {*} param0 * @param {TestSuite[]} param0.testSuites - test suites to save as CSV * @param {string} param0.outputDir - where to save files to */ export default function writeTestSuites({ testSuites, outputDir }) { - const { mainCsv, appendixCsv } = createCsvs(); - - outputDir = createTestSuitesSubDirectory(outputDir); - - writeSheet({ - content: testSuites.flatMap(suite => suite.testCases), - outputDir, - fileName: "plan-tech-test-suite.csv", - ...mainCsv - }); - - writeSheet({ - content: testSuites.flatMap(suite => suite.appendix), - outputDir, - fileName: "plan-tech-test-suite-appendix.csv", - ...appendixCsv - }); + log(`Creating CSVs for test suites`); + const { mainCsv, appendixCsv } = createCsvs(); + + outputDir = createTestSuitesSubDirectory(outputDir); + + writeSheet({ + content: testSuites.flatMap((suite) => suite.testCases), + outputDir, + fileName: "plan-tech-test-suite.csv", + ...mainCsv, + }); + + writeSheet({ + content: testSuites.flatMap((suite) => suite.appendix), + outputDir, + fileName: "plan-tech-test-suite-appendix.csv", + ...appendixCsv, + }); } const createTestSuitesSubDirectory = (outputDir) => { - const subDirectory = path.join(outputDir, "test-suites"); + const subDirectory = path.join(outputDir, "test-suites"); - try { - fs.mkdirSync(subDirectory, { recursive: true }); - } - catch (e) { - console.error(`Error creating test-suites subdirectory`, e); - return outputDir; - } + try { + fs.mkdirSync(subDirectory, { recursive: true }); + } catch (e) { + console.error(`Error creating test-suites subdirectory`, e); + return outputDir; + } - return subDirectory; + return subDirectory; }; const writeSheet = ({ content, headers, keys, fileName, outputDir }) => { - const rows = content - .filter((testCase) => testCase != null) - .map((testCase) => `"${keys.map(key => testCase[key] ?? " ").join(`","`)}"`) - .join("\n"); - - const headerRow = headers.join(","); - const csv = `${headerRow}\n${rows}`; - - const filePath = path.join(outputDir, fileName); - - fs.writeFileSync(filePath, csv); -}; \ No newline at end of file + log(`Writing sheet for ${fileName}`); + const rows = content + .filter((testCase) => testCase != null) + .map( + (testCase) => + `"${keys.map((key) => testCase[key] ?? " ").join(`","`)}"` + ) + .join("\n"); + + const headerRow = headers.join(","); + const csv = `${headerRow}\n${rows}`; + + const filePath = path.join(outputDir, fileName); + + fs.writeFileSync(filePath, csv); + log(`Wrote for ${fileName}`); +}; diff --git a/contentful/export-processor/user-journey/minimum-path-calculator.js b/contentful/export-processor/user-journey/minimum-path-calculator.js index e71a4689a..a94def60a 100644 --- a/contentful/export-processor/user-journey/minimum-path-calculator.js +++ b/contentful/export-processor/user-journey/minimum-path-calculator.js @@ -29,8 +29,8 @@ export class MinimumPathCalculator { sectionId; /** - * - * @param {{ questions: Question[], paths: UserJourney[], sortedPaths: UserJourney[], sectionId: string}} params + * + * @param {{ questions: Question[], paths: UserJourney[], sortedPaths: UserJourney[], sectionId: string}} params */ constructor({ questions, paths, sortedPaths, sectionId }) { this.questions = questions; @@ -80,16 +80,19 @@ export class MinimumPathCalculator { minimumPaths.push(path.path); - this.removeItemsFromArray(remainingQuestions, path.questionIdsAnswered); + this.removeItemsFromArray( + remainingQuestions, + path.questionIdsAnswered + ); } return this.handleRemainingQuestions(remainingQuestions, minimumPaths); } /** - * - * @param {string[]} remainingQuestions - * @param {Path[]} minimumPaths + * + * @param {string[]} remainingQuestions + * @param {Path[]} minimumPaths * @returns {Path[]} */ handleRemainingQuestions(remainingQuestions, minimumPaths) { @@ -105,9 +108,9 @@ export class MinimumPathCalculator { } /** - * - * @param {string} questionId - * @returns + * + * @param {string} questionId + * @returns */ getFirstPathContainingQuestion(questionId) { // Find the first path that contains the remaining question diff --git a/contentful/export-processor/user-journey/path-calculator.js b/contentful/export-processor/user-journey/path-calculator.js index 74a79d317..b32a19ee8 100644 --- a/contentful/export-processor/user-journey/path-calculator.js +++ b/contentful/export-processor/user-journey/path-calculator.js @@ -16,28 +16,28 @@ import errorLogger from "#src/errors/error-logger"; export class PathCalculator { /** @type {Question[]} Questions in the section */ questions; - + /** @type {UserJourney[]} All possible paths through the questions */ paths; - + /** @type {PathBuilder} Builds paths through questions */ pathBuilder; - + /** @type {SubtopicRecommendation} Recommendation for the section */ recommendation; - + /** @type {string} Unique identifier for the section */ sectionId; /** @type {UserJourney[] | undefined} Cached sorted paths */ _sortedPaths; - + /** @type {UserJourney[] | undefined} Cached minimum paths to navigate questions */ _minimumPathsToNavigateQuestions; - + /** @type {Record | undefined} Cached minimum paths for recommendations */ _minimumPathsForRecommendations; - + /** @type {UserJourney[] | undefined} Cached paths for all possible answers */ _pathsForAllPossibleAnswers; @@ -124,8 +124,8 @@ export class PathCalculator { } /** - * - * @param {Path} path + * + * @param {Path} path * @returns {UserJourney} */ pathToUserJourney(path) { @@ -161,7 +161,11 @@ export class PathCalculator { if (sectionChunks.length !== uniqueTestedChunks.length) { sectionChunks .filter((chunkId) => !uniqueTestedChunks.includes(chunkId)) - .forEach((missingChunk) => this._addError(`Recommendation chunk ${missingChunk} not included in any test paths.`)); + .forEach((missingChunk) => + this._addError( + `Recommendation chunk ${missingChunk} not included in any test paths.` + ) + ); } } @@ -186,7 +190,9 @@ export class PathCalculator { continue; } - this._addError(`Could not find question with ID '${nextQuestionId}' for answer ${answer.text} ${answer.id} in ${question.text} ${question.id}`); + this._addError( + `Could not find question with ID '${nextQuestionId}' for answer ${answer.text} ${answer.id} in ${question.text} ${question.id}` + ); answer.nextQuestion = null; } } @@ -194,14 +200,13 @@ export class PathCalculator { /** * Adds message to error logger using the section's id. - * @param {string} message + * @param {string} message */ - _addError(message){ + _addError(message) { errorLogger.addError({ id: this.sectionId, contentType: "section", - message: message + message: message, }); - } } diff --git a/contentful/export-processor/user-journey/path-part.js b/contentful/export-processor/user-journey/path-part.js index b497b652b..c4680c295 100644 --- a/contentful/export-processor/user-journey/path-part.js +++ b/contentful/export-processor/user-journey/path-part.js @@ -16,8 +16,15 @@ export default class PathPart { * @param {Question} params.question - The question in this path part * @param {Answer} params.answer - The answer selected for this question */ - constructor({ question, answer }){ + constructor({ question, answer }) { this.question = question; this.answer = answer; } -} \ No newline at end of file + + toMinimalOutput() { + return { + question: this.question.text, + answer: this.answer.text, + }; + } +} diff --git a/contentful/export-processor/user-journey/user-journey.js b/contentful/export-processor/user-journey/user-journey.js index 715afb8b0..08f057b3e 100644 --- a/contentful/export-processor/user-journey/user-journey.js +++ b/contentful/export-processor/user-journey/user-journey.js @@ -122,10 +122,14 @@ export class UserJourney { */ mapPathToOnlyQuestionAnswerTexts() { return this.path.map((pathPart) => { - return { - question: pathPart.question.text, - answer: pathPart.answer.text, - }; + try { + return pathPart.toMinimalOutput(); + } catch (e) { + return { + question: pathPart.question.text, + answer: pathPart.answer.text, + }; + } }); } diff --git a/contentful/export-processor/write-user-journey-paths.js b/contentful/export-processor/write-user-journey-paths.js index 56a8909c0..d49cf2b73 100644 --- a/contentful/export-processor/write-user-journey-paths.js +++ b/contentful/export-processor/write-user-journey-paths.js @@ -2,7 +2,12 @@ import fs from "fs"; // eslint-disable-next-line no-unused-vars import DataMapper from "./data-mapper.js"; import path from "path"; -import { stringify } from "flatted"; +import { stringify } from "#src/helpers/json"; +import { log } from "#src/helpers/log"; + +/** + * @typedef {import("#src/content-types/section").SectionMinimalOutput} SectionMinimalOutput + */ /** * @param {object} args @@ -10,61 +15,124 @@ import { stringify } from "flatted"; * @param {string} args.outputDir - directory to write journey paths to * @param {boolean} args.saveAllJourneys - if true saves _all_ user journeys, otherwise writes 1 (or more if needed) to navigate through every question, and 1 per maturity */ -export default function writeUserJourneyPaths({ dataMapper, outputDir, saveAllJourneys }) { - outputDir = createUserJourneyDirectory(outputDir); +export default function writeUserJourneyPaths({ + dataMapper, + outputDir, + saveAllJourneys, +}) { + log(`Writing all user journey paths`); + + outputDir = createUserJourneyDirectory(outputDir); - const journeys = mapJourneysToMinimalSectionInfo(dataMapper, saveAllJourneys); + const journeys = mapJourneysToMinimalSectionInfo( + dataMapper, + saveAllJourneys + ); - saveUserJourneys(journeys, outputDir); - saveSubtopicPathsOverview(journeys, outputDir); + saveUserJourneys(journeys, outputDir); + saveSubtopicPathsOverview(journeys, outputDir); + + log(`Finished writing all user journey paths`); } /** - * - * @param {DataMapper} dataMapper - * @param {boolean} saveAllJourneys - * @returns + * + * @param {DataMapper} dataMapper + * @param {boolean} saveAllJourneys + * @returns {SectionMinimalOutput[]} */ -const mapJourneysToMinimalSectionInfo = (dataMapper, saveAllJourneys) => dataMapper.mappedSections.map(section => section.toMinimalOutput(saveAllJourneys)); +const mapJourneysToMinimalSectionInfo = (dataMapper, saveAllJourneys) => + dataMapper.mappedSections.map((section) => + section.toMinimalOutput(saveAllJourneys) + ); +/** + * + * @param {SectionMinimalOutput[]} journeys + * @param {string} outputDir + */ const saveSubtopicPathsOverview = (journeys, outputDir) => { - const combined = journeys.map(journey => ({ - subtopic: journey.section, - stats: journey.allPathsStats, - })); - - try { - fs.writeFileSync(`${outputDir}subtopic-paths-overview.json`, JSON.stringify(combined)); - } - catch (e) { - console.error(`Error saving subtopic paths overview`, e); - } + log(`Writing all subtopic paths overview`, { addBlankLine: false }); + + const combined = journeys.map((journey) => ({ + subtopic: journey.section, + stats: journey.allPathsStats, + })); + + try { + fs.writeFileSync( + `${outputDir}subtopic-paths-overview.json`, + stringify(combined) + ); + } catch (e) { + console.error(`Error saving subtopic paths overview`, e); + } + + log(`Wrote all subtopic paths overview`); }; +/** + * + * @param {SectionMinimalOutput[]} journeys + * @param {string} outputDir + */ const saveUserJourneys = (journeys, outputDir) => { - for (const journey of journeys) { - const fileName = journey.section + ".json"; - const filePath = path.join(outputDir, fileName); + log(`Writing user journey path files`); + for (const journey of journeys) { + const fileName = journey.section + ".json"; + const filePath = path.join(outputDir, fileName); - try { - fs.writeFileSync(filePath, JSON.stringify(journey)); + saveUserJourney(journey, filePath); } - catch (e) { - console.error(`Error saving user journeys for ${journey.section}. Will try flatted`, e); - fs.writeFileSync(filePath, stringify(journey)); + + log(`Wrote all user journey paths`); +}; + +/** + * + * @param {SectionMinimalOutput} journey + * @param {string} filePath + */ +const saveUserJourney = (journey, filePath) => { + log(`Writing user journey path file for ${journey.section}`); + + try { + fs.writeFileSync(filePath, stringify(journey)); + } catch (e) { + console.error(`Error saving user journeys for ${journey.section}`, e); + + if (!journey.allPossiblePaths || journey.allPossiblePaths.length == 0) { + return; + } + log( + `Will clearing all possible paths from subtopic paths and try again` + ); + + journey.allPossiblePaths = undefined; + saveUserJourney(journey, filePath); } - } + + log(`Wrote user journey path file for ${journey.section}`, { + addSeperator: true, + }); }; +/** + * + * @param {string} outputDir + * @returns + */ const createUserJourneyDirectory = (outputDir) => { - const userJourneyDirectory = path.join(outputDir, "user-journeys"); - - try { - fs.mkdirSync(userJourneyDirectory); - return userJourneyDirectory; - } - catch (e) { - console.error(`Error creating user journey directory ${userJourneyDirectory}`, e); - return outputDir; - } + const userJourneyDirectory = path.join(outputDir, "user-journeys"); + + try { + fs.mkdirSync(userJourneyDirectory); + return userJourneyDirectory; + } catch (e) { + console.error( + `Error creating user journey directory ${userJourneyDirectory}`, + e + ); + return outputDir; + } }; From d58c47cc171cdd11d480d2daa3cee272cc925f75 Mon Sep 17 00:00:00 2001 From: Drew MORGAN Date: Mon, 20 Jan 2025 15:12:04 +0000 Subject: [PATCH 03/14] Add/update terraform docs --- terraform/container-app/README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/terraform/container-app/README.md b/terraform/container-app/README.md index d87137ce9..11e6dafdb 100644 --- a/terraform/container-app/README.md +++ b/terraform/container-app/README.md @@ -89,7 +89,7 @@ Terraform needs to be initialised on your local machine before you can use it. T Run the following command to initialise Terraform. -`terraform init -backend-config=backend.tfvars` +`terraform init -backend-config=backend.tfvars -upgrade -reconfigure` ⚠️ tfvars files are ignored by git, but please ensure they do not get committed to the repo by accident ⚠️ @@ -111,15 +111,19 @@ To run the plan command, first rename the `terraform.tfvars.example` file to `te | az_sql_admin_userid_postfix | User for the SQL server (this will be prepended by the resource group name) | | az_app_kestrel_endpoint | Expected endpoint for the Kestrel server (e.g. "https://127.0.0.1:8080) | -Run the following command to execute the Plan commande: +Run the following command to execute the Plan command: `terraform plan -var-file="terraform.tfvars"` -### Terraform Plan +**NB: Make sure that the image_tag field is set to the most recent container address - usually of the form `main...`. The current container can be found through Azure Portal -> Container Registries -> Services -> Repositories -> plan-tech-app** + +For the moment, if you get a 403 then give IP-specific access to the person deploying the change. (Permissions may be needed.) + +### Terraform Apply Use the apply command to apply the changes to the Azure environment. -`terraform plan -var-file="terraform.tfvars"` +`terraform apply -var-file="terraform.tfvars"` ### Terraform Validate From 3974bba0de81f50161d9055b12fc11c93b9baf8a Mon Sep 17 00:00:00 2001 From: gilaineyo Date: Tue, 21 Jan 2025 10:03:07 +0000 Subject: [PATCH 04/14] chore: adds LF end-of-line and associated settings --- .editorconfig | 73 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/.editorconfig b/.editorconfig index 729d5f838..c98614cf7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -30,16 +30,81 @@ csharp_style_unused_value_assignment_preference = discard_variable csharp_style_unused_value_expression_statement_preference = discard_variable # Expression-level preferences -dotnet_style_coalesce_expression = true +dotnet_style_coalesce_expression = true:suggestion dotnet_style_collection_initializer = true -dotnet_style_null_propagation = true +dotnet_style_null_propagation = true:suggestion dotnet_style_operator_placement_when_wrapping = beginning_of_line -dotnet_style_prefer_auto_properties = true +dotnet_style_prefer_auto_properties = true:silent dotnet_style_prefer_compound_assignment = true -dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion dotnet_style_prefer_simplified_boolean_expressions = true dotnet_style_prefer_simplified_interpolation = true ## Set warnings to errors to be auto-fixed by dotnet format # Using directive is unnecessary dotnet_diagnostic.IDE0005.severity = error +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_braces = true:silent +csharp_style_namespace_declarations = block_scoped:silent +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_indent_labels = one_less_than_current + +[*.{cs,vb}] +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case +end_of_line = lf From ec20888b69ac33ce3faa10d8863a472883ff152f Mon Sep 17 00:00:00 2001 From: Drew MORGAN Date: Tue, 21 Jan 2025 13:15:07 +0000 Subject: [PATCH 05/14] bug: 237416 Change code to reference UsePreviewApi variable --- .../Persistence/Models/ContentfulOptions.cs | 2 +- src/Dfe.PlanTech.Web/Controllers/QuestionsController.cs | 2 +- src/Dfe.PlanTech.Web/Controllers/RecommendationsController.cs | 2 +- src/Dfe.PlanTech.Web/README.md | 4 ++-- src/Dfe.PlanTech.Web/appsettings.Staging.json | 1 - src/Dfe.PlanTech.Web/appsettings.json | 2 +- 6 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/Dfe.PlanTech.Domain/Persistence/Models/ContentfulOptions.cs b/src/Dfe.PlanTech.Domain/Persistence/Models/ContentfulOptions.cs index 957f6a296..e19621745 100644 --- a/src/Dfe.PlanTech.Domain/Persistence/Models/ContentfulOptions.cs +++ b/src/Dfe.PlanTech.Domain/Persistence/Models/ContentfulOptions.cs @@ -1,6 +1,6 @@ namespace Dfe.PlanTech.Domain.Persistence.Models; -public record ContentfulOptions(bool UsePreview) +public record ContentfulOptions(bool UsePreviewApi) { public ContentfulOptions() : this(false) { diff --git a/src/Dfe.PlanTech.Web/Controllers/QuestionsController.cs b/src/Dfe.PlanTech.Web/Controllers/QuestionsController.cs index 465bd67c1..ec15bf768 100644 --- a/src/Dfe.PlanTech.Web/Controllers/QuestionsController.cs +++ b/src/Dfe.PlanTech.Web/Controllers/QuestionsController.cs @@ -70,7 +70,7 @@ public async Task GetQuestionPreviewById(string questionId, [FromServices] ContentfulOptions contentfulOptions, CancellationToken cancellationToken = default) { - if (!contentfulOptions.UsePreview) + if (!contentfulOptions.UsePreviewApi) return new RedirectResult(UrlConstants.SelfAssessmentPage); var question = await _getEntityFromContentfulQuery.GetEntityById(questionId, cancellationToken) ?? diff --git a/src/Dfe.PlanTech.Web/Controllers/RecommendationsController.cs b/src/Dfe.PlanTech.Web/Controllers/RecommendationsController.cs index ece4957db..e15c01d0b 100644 --- a/src/Dfe.PlanTech.Web/Controllers/RecommendationsController.cs +++ b/src/Dfe.PlanTech.Web/Controllers/RecommendationsController.cs @@ -41,7 +41,7 @@ public async Task GetRecommendationPreview(string sectionSlug, [FromServices] IGetRecommendationRouter getRecommendationRouter, CancellationToken cancellationToken) { - if (!contentfulOptions.UsePreview) + if (!contentfulOptions.UsePreviewApi) { return new RedirectResult(UrlConstants.SelfAssessmentPage); } diff --git a/src/Dfe.PlanTech.Web/README.md b/src/Dfe.PlanTech.Web/README.md index e729ffd2b..a4b5a8709 100644 --- a/src/Dfe.PlanTech.Web/README.md +++ b/src/Dfe.PlanTech.Web/README.md @@ -48,5 +48,5 @@ The collection of all secrets is stored in the Azure keyvault, which you can use ## Using a local Redis or Database instance -- If you want to use a local instance of Redis, instructions are provided in the [Readme](../Dfe.PlanTech.Infrastructure.Redis/README.md)) -- If you want to use a local database, the [SeedTestData Readme](tests/Dfe.PlanTech.Web.SeedTestData/README.md) contains instructions on setting up a local test database, and changing the connection string to use it +- If you want to use a local instance of Redis, instructions are provided in the [Readme](../Dfe.PlanTech.Infrastructure.Redis/README.md) +- If you want to use a local database, the [SeedTestData Readme](../../tests/Dfe.PlanTech.Web.SeedTestData/README.md) contains instructions on setting up a local test database, and changing the connection string to use it diff --git a/src/Dfe.PlanTech.Web/appsettings.Staging.json b/src/Dfe.PlanTech.Web/appsettings.Staging.json index a455e0c67..fdc064fee 100644 --- a/src/Dfe.PlanTech.Web/appsettings.Staging.json +++ b/src/Dfe.PlanTech.Web/appsettings.Staging.json @@ -4,7 +4,6 @@ "SiteVerificationId": "" }, "Contentful": { - "UsePreview": true, "UsePreviewApi": true } } diff --git a/src/Dfe.PlanTech.Web/appsettings.json b/src/Dfe.PlanTech.Web/appsettings.json index a097b97a9..1027e2e4c 100644 --- a/src/Dfe.PlanTech.Web/appsettings.json +++ b/src/Dfe.PlanTech.Web/appsettings.json @@ -52,7 +52,7 @@ ] }, "Contentful": { - "UsePreview": false + "UsePreviewApi": false }, "cs:supportedAssetTypes": { "ImageTypes": ["image/jpeg", "image/png"], From 2efcf0c12c9854c7791fb73af74206d50f750f1e Mon Sep 17 00:00:00 2001 From: Jag Nahl Date: Tue, 21 Jan 2025 16:10:26 +0000 Subject: [PATCH 06/14] 246010 - Typo fix on the word assessment. --- contentful/export-processor/test-suite/test-suite.js | 4 ++-- .../Views/Shared/Components/VerticalNavigation/Default.cshtml | 4 ++-- .../cypress/e2e/pages/recommendation-page.cy.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contentful/export-processor/test-suite/test-suite.js b/contentful/export-processor/test-suite/test-suite.js index 739aef370..87bdd9e91 100644 --- a/contentful/export-processor/test-suite/test-suite.js +++ b/contentful/export-processor/test-suite/test-suite.js @@ -263,12 +263,12 @@ export default class TestSuiteForSubTopic { } generateUseBackButton() { - const testScenario = `User uses back button to navigate back through questions to self assesment page`; + const testScenario = `User uses back button to navigate back through questions to self assessment page`; const testSteps = `1 - Navigate to the ${this.subtopicName} subtopic 2 - Navigate through the interstitial page 3 - Answer first question, save and continue 4 - Use back button to return to first queston - 5 - use back button again to return to self assesment page`; + 5 - use back button again to return to self assessment page`; const expectedOutcome = `User returned to self - assessment page.${this.subtopicName} subtopic shows 'In progress'.`; return this.createRow(testScenario, testSteps, expectedOutcome); } diff --git a/src/Dfe.PlanTech.Web/Views/Shared/Components/VerticalNavigation/Default.cshtml b/src/Dfe.PlanTech.Web/Views/Shared/Components/VerticalNavigation/Default.cshtml index 37475fae6..dd42eeddd 100644 --- a/src/Dfe.PlanTech.Web/Views/Shared/Components/VerticalNavigation/Default.cshtml +++ b/src/Dfe.PlanTech.Web/Views/Shared/Components/VerticalNavigation/Default.cshtml @@ -2,7 +2,7 @@ \ No newline at end of file + diff --git a/tests/Dfe.PlanTech.Web.E2ETests/cypress/e2e/pages/recommendation-page.cy.js b/tests/Dfe.PlanTech.Web.E2ETests/cypress/e2e/pages/recommendation-page.cy.js index 3a0ed705a..749901a93 100644 --- a/tests/Dfe.PlanTech.Web.E2ETests/cypress/e2e/pages/recommendation-page.cy.js +++ b/tests/Dfe.PlanTech.Web.E2ETests/cypress/e2e/pages/recommendation-page.cy.js @@ -115,7 +115,7 @@ describe("Recommendation Page", () => { cy.get( "nav.dfe-vertical-nav div.dfe-vertical-nav__back-button a.govuk-back-link" ) - .contains("Go to self-assesment topics") + .contains("Go to self-assessment topics") .should("exist") .and("have.attr", "href") .and("include", url); From 25c0ab632103eecb6f2c7fd5e2bed5a627d0298c Mon Sep 17 00:00:00 2001 From: DrewAire Date: Wed, 22 Jan 2025 13:43:30 +0000 Subject: [PATCH 07/14] Update terraform/container-app/README.md Co-authored-by: Katie Gardner <114991656+katie-gardner@users.noreply.github.com> --- terraform/container-app/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terraform/container-app/README.md b/terraform/container-app/README.md index 29c4acefc..a71d48554 100644 --- a/terraform/container-app/README.md +++ b/terraform/container-app/README.md @@ -124,7 +124,7 @@ Run the following command to execute the Plan command: **NB: Make sure that the image_tag field is set to the most recent container address - usually of the form `main...`. The current container can be found through Azure Portal -> Container Registries -> Services -> Repositories -> plan-tech-app** -For the moment, if you get a 403 then give IP-specific access to the person deploying the change. (Permissions may be needed.) +For the moment, if you get a 403 then give IP-specific access to the `plantechcosting` storage account for the relevant environment to the person deploying the change. (Permissions may be needed.) ### Terraform Apply From d17dc157c012d2bed02827132fc72f53ad85df2c Mon Sep 17 00:00:00 2001 From: Kenny Lawrie Date: Thu, 30 Jan 2025 10:17:58 +0000 Subject: [PATCH 08/14] added update and tests --- .../Extensions/StringExtensions.cs | 12 +++++++++++ src/Dfe.PlanTech.Web/Models/PageViewModel.cs | 3 ++- .../Views/Shared/Components/Title.cshtml | 3 ++- .../Helpers/StringExtensionsTests.cs | 21 +++++++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 tests/Dfe.PlanTech.Web.UnitTests/Helpers/StringExtensionsTests.cs diff --git a/src/Dfe.PlanTech.Application/Extensions/StringExtensions.cs b/src/Dfe.PlanTech.Application/Extensions/StringExtensions.cs index a2920c852..cd7bc383c 100644 --- a/src/Dfe.PlanTech.Application/Extensions/StringExtensions.cs +++ b/src/Dfe.PlanTech.Application/Extensions/StringExtensions.cs @@ -1,7 +1,10 @@ +using System.Net; + namespace Dfe.PlanTech; public static class StringExtensions { + public const string NonBreakingHyphen = "‑"; public static string FirstCharToUpper(this string input) { if (string.IsNullOrEmpty(input)) @@ -12,4 +15,13 @@ public static string FirstCharToUpper(this string input) input.AsSpan(0, 1).ToUpperInvariant(destination); return $"{destination}{input.AsSpan(1)}"; } + + public static string? UseNonBreakingHyphenAndHtmlDecode(this string text) + { + if (string.IsNullOrEmpty(text)) + { + return text; + } + return WebUtility.HtmlDecode(text.Replace("-", NonBreakingHyphen)); + } } diff --git a/src/Dfe.PlanTech.Web/Models/PageViewModel.cs b/src/Dfe.PlanTech.Web/Models/PageViewModel.cs index a1e76a7d8..5998d41a8 100644 --- a/src/Dfe.PlanTech.Web/Models/PageViewModel.cs +++ b/src/Dfe.PlanTech.Web/Models/PageViewModel.cs @@ -10,7 +10,8 @@ public class PageViewModel public PageViewModel(Page page, Controller controller, IUser user, ILogger logger, bool displayBlueBanner = true) { - controller.ViewData["Title"] = System.Net.WebUtility.HtmlDecode(page.Title?.Text) ?? + + controller.ViewData["Title"] = StringExtensions.UseNonBreakingHyphenAndHtmlDecode(page.Title?.Text) ?? "Plan Technology For Your School"; Page = page; DisplayBlueBanner = displayBlueBanner; diff --git a/src/Dfe.PlanTech.Web/Views/Shared/Components/Title.cshtml b/src/Dfe.PlanTech.Web/Views/Shared/Components/Title.cshtml index 89f96cc08..12980692f 100644 --- a/src/Dfe.PlanTech.Web/Views/Shared/Components/Title.cshtml +++ b/src/Dfe.PlanTech.Web/Views/Shared/Components/Title.cshtml @@ -1,3 +1,4 @@ +@using Dfe.PlanTech @model Dfe.PlanTech.Domain.Content.Models.Title; -

@System.Net.WebUtility.HtmlDecode(Model.Text)

\ No newline at end of file +

@StringExtensions.UseNonBreakingHyphenAndHtmlDecode(Model.Text)

diff --git a/tests/Dfe.PlanTech.Web.UnitTests/Helpers/StringExtensionsTests.cs b/tests/Dfe.PlanTech.Web.UnitTests/Helpers/StringExtensionsTests.cs new file mode 100644 index 000000000..74325e35c --- /dev/null +++ b/tests/Dfe.PlanTech.Web.UnitTests/Helpers/StringExtensionsTests.cs @@ -0,0 +1,21 @@ +using Xunit; + +namespace Dfe.PlanTech.Web.UnitTests.Helpers +{ + public class StringExtensionsTests + { + [Theory] + [InlineData("Single test-", "Single test‑")] + [InlineData("Single-test", "Single‑test")] + [InlineData("This is -a-test-", "This is ‑a‑test‑")] + [InlineData("This is-a-test", "This is‑a‑test")] + [InlineData(null, null)] + [InlineData("", "")] + public void CheckHyphensConvertedCorrectly(string inputText, string expectedText) + { + var result = StringExtensions.UseNonBreakingHyphenAndHtmlDecode(inputText); + + Assert.Equal(expectedText, result); + } + } +} From ed9670dae54acbf3ed38d793f6a7c7a75b8cbe60 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Jan 2025 14:47:36 +0000 Subject: [PATCH 09/14] chore: Linted code for plan-technology-for-your-school.sln solution --- .../Dfe.PlanTech.Web.UnitTests/Helpers/StringExtensionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Dfe.PlanTech.Web.UnitTests/Helpers/StringExtensionsTests.cs b/tests/Dfe.PlanTech.Web.UnitTests/Helpers/StringExtensionsTests.cs index 74325e35c..f3861e9b9 100644 --- a/tests/Dfe.PlanTech.Web.UnitTests/Helpers/StringExtensionsTests.cs +++ b/tests/Dfe.PlanTech.Web.UnitTests/Helpers/StringExtensionsTests.cs @@ -11,7 +11,7 @@ public class StringExtensionsTests [InlineData("This is-a-test", "This is‑a‑test")] [InlineData(null, null)] [InlineData("", "")] - public void CheckHyphensConvertedCorrectly(string inputText, string expectedText) + public void CheckHyphensConvertedCorrectly(string? inputText, string? expectedText) { var result = StringExtensions.UseNonBreakingHyphenAndHtmlDecode(inputText); From 95a4851a43634d411f998a9796b5917a1a47e072 Mon Sep 17 00:00:00 2001 From: Kenny Lawrie Date: Thu, 30 Jan 2025 14:50:46 +0000 Subject: [PATCH 10/14] added comment --- .../Dfe.PlanTech.Web.UnitTests/Helpers/StringExtensionsTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Dfe.PlanTech.Web.UnitTests/Helpers/StringExtensionsTests.cs b/tests/Dfe.PlanTech.Web.UnitTests/Helpers/StringExtensionsTests.cs index 74325e35c..695dad7a2 100644 --- a/tests/Dfe.PlanTech.Web.UnitTests/Helpers/StringExtensionsTests.cs +++ b/tests/Dfe.PlanTech.Web.UnitTests/Helpers/StringExtensionsTests.cs @@ -4,6 +4,7 @@ namespace Dfe.PlanTech.Web.UnitTests.Helpers { public class StringExtensionsTests { + // Just to be clear, the expectedText Hyphens are Non breaking Hyphens and inputText Hyphens are standard [Theory] [InlineData("Single test-", "Single test‑")] [InlineData("Single-test", "Single‑test")] From 5a759fedf2955b60493ff984e0df31502441b746 Mon Sep 17 00:00:00 2001 From: Kenny Lawrie Date: Thu, 30 Jan 2025 16:47:05 +0000 Subject: [PATCH 11/14] updated parameter --- src/Dfe.PlanTech.Application/Extensions/StringExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Dfe.PlanTech.Application/Extensions/StringExtensions.cs b/src/Dfe.PlanTech.Application/Extensions/StringExtensions.cs index cd7bc383c..e558debca 100644 --- a/src/Dfe.PlanTech.Application/Extensions/StringExtensions.cs +++ b/src/Dfe.PlanTech.Application/Extensions/StringExtensions.cs @@ -16,7 +16,7 @@ public static string FirstCharToUpper(this string input) return $"{destination}{input.AsSpan(1)}"; } - public static string? UseNonBreakingHyphenAndHtmlDecode(this string text) + public static string? UseNonBreakingHyphenAndHtmlDecode(this string? text) { if (string.IsNullOrEmpty(text)) { From 86bfb120932891c00013ece182002d61b36a4682 Mon Sep 17 00:00:00 2001 From: Drew MORGAN Date: Mon, 3 Feb 2025 10:36:13 +0000 Subject: [PATCH 12/14] Update expected date string for FormattedDateShort_Should_Display_Correctly --- .../Helpers/DateTimeFormatterTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Dfe.PlanTech.Domain.UnitTests/Helpers/DateTimeFormatterTests.cs b/tests/Dfe.PlanTech.Domain.UnitTests/Helpers/DateTimeFormatterTests.cs index 11892c303..2e6f98694 100644 --- a/tests/Dfe.PlanTech.Domain.UnitTests/Helpers/DateTimeFormatterTests.cs +++ b/tests/Dfe.PlanTech.Domain.UnitTests/Helpers/DateTimeFormatterTests.cs @@ -25,7 +25,7 @@ public void FormattedDateLong_Should_Display_Correctly(string inputDate, string } [Theory] - [InlineData("2015/09/15", "15 Sep 2015")] + [InlineData("2015/09/15", "15 Sept 2015")] [InlineData("2020/01/09", "9 Jan 2020")] public void FormattedDateShort_Should_Display_Correctly(string inputDate, string expected) { From 66bc6e8a28449732a19e3e238f7896195e56099a Mon Sep 17 00:00:00 2001 From: Drew MORGAN Date: Mon, 3 Feb 2025 10:47:13 +0000 Subject: [PATCH 13/14] Account for different outputs from different libraries on local and server. --- .../Helpers/DateTimeFormatterTests.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/Dfe.PlanTech.Domain.UnitTests/Helpers/DateTimeFormatterTests.cs b/tests/Dfe.PlanTech.Domain.UnitTests/Helpers/DateTimeFormatterTests.cs index 2e6f98694..6c716e7d0 100644 --- a/tests/Dfe.PlanTech.Domain.UnitTests/Helpers/DateTimeFormatterTests.cs +++ b/tests/Dfe.PlanTech.Domain.UnitTests/Helpers/DateTimeFormatterTests.cs @@ -25,11 +25,17 @@ public void FormattedDateLong_Should_Display_Correctly(string inputDate, string } [Theory] - [InlineData("2015/09/15", "15 Sept 2015")] + [InlineData("2015/09/15", "15 Sep 2015")] [InlineData("2020/01/09", "9 Jan 2020")] public void FormattedDateShort_Should_Display_Correctly(string inputDate, string expected) { var dateTime = DateTime.Parse(inputDate, new CultureInfo("en-GB")); - Assert.Equal(expected, DateTimeFormatter.FormattedDateShort(dateTime)); + + // TODO: This is a horrible hack to account for the fact that + // the ICU library updated "Sep" to "Sept" in version 68. + // Further information: + // https://stackoverflow.com/questions/77430109/trouble-with-abbreviatedmonthnames + // https://cldr.unicode.org/downloads/cldr-38 + Assert.Equal(expected, DateTimeFormatter.FormattedDateShort(dateTime).Replace("Sept", "Sep")); } } From b5397dc8242d6012fc48cac85730a645cf2f5025 Mon Sep 17 00:00:00 2001 From: Drew MORGAN Date: Mon, 3 Feb 2025 11:03:23 +0000 Subject: [PATCH 14/14] Fix missing mirror server in apt-get install by running apt-get update beforehand --- .github/actions/run-unit-tests/action.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/actions/run-unit-tests/action.yml b/.github/actions/run-unit-tests/action.yml index 5e6c78ffc..b553d4d29 100644 --- a/.github/actions/run-unit-tests/action.yml +++ b/.github/actions/run-unit-tests/action.yml @@ -14,6 +14,10 @@ runs: shell: bash run: dotnet test ${{ inputs.solution_filename }} --no-restore --verbosity normal --collect:"XPlat Code Coverage" --logger:"trx;LogFileName=test-results.trx" || true + - name: Update apt-get packages + shell: bash + run: sudo apt-get update + - name: Install XML tools shell: bash run: sudo apt-get install -y libxml2-utils