Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2705 (2024-03-13 04:52:55)

test: add Edit Task modal snapshot test
<!--- Provide a general summary of your changes in the Title above -->

# Description

Add HTML snapshot test to verify/void changes in the Edit Task modal.

## Motivation and Context

Have a "state" test of the task modal. So far we had only "action" tests - task editing (field change -> changed task) & task rendering (task -> field with correct task data).

## How has this been tested?

Snapshot test =)


## Types of changes

- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)


## Checklist


- [x] My code follows the code style of this project and passes `yarn run lint`.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

- [x] My contribution follow this project's [contributing guide](https://publish.obsidian.md/tasks-contributing)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)



Patch:
diff --git a/docs/Queries/Explaining Queries.md b/docs/Queries/Explaining Queries.md
index da277fa6..bc8f0f24 100644
--- a/docs/Queries/Explaining Queries.md
+++ b/docs/Queries/Explaining Queries.md
@@ -149,6 +149,13 @@ the results begin with the following, on `2022-10-21`:
 ```text
 Explanation of this Tasks code block query:
 
+  (                                                                                       \
+      (description includes 1) AND (description includes 2) AND (description includes 3)  \
+  ) OR (                                                                                  \
+      (description includes 5) AND (description includes 6) AND (description includes 7)  \
+  )                                                                                       \
+  AND NOT (description includes 7)
+   =>
   ( (description includes 1) AND (description includes 2) AND (description includes 3) ) OR ( (description includes 5) AND (description includes 6) AND (description includes 7) ) AND NOT (description includes 7) =>
     OR (At least one of):
       AND (All of):
@@ -247,14 +254,19 @@ the results begin with the following:
 ```text
 Explanation of this Tasks code block query:
 
+  path includes {{query.file.path}} =>
   path includes some/sample/file path.md
 
+  root includes {{query.file.root}} =>
   root includes some/
 
+  folder includes {{query.file.folder}} =>
   folder includes some/sample/
 
+  filename includes {{query.file.filename}} =>
   filename includes file path.md
 
+  description includes Some Cryptic String {{! Inline comments are removed before search }} =>
   description includes Some Cryptic String 
 
   No grouping instructions supplied.
diff --git a/docs/Queries/Line Continuations.md b/docs/Queries/Line Continuations.md
index a808c41f..e9378d26 100644
--- a/docs/Queries/Line Continuations.md
+++ b/docs/Queries/Line Continuations.md
@@ -31,6 +31,9 @@ explain
 ```text
 Explanation of this Tasks code block query:
 
+  (priority is highest) OR       \
+      (priority is lowest)
+   =>
   (priority is highest) OR (priority is lowest) =>
     OR (At least one of):
       priority is highest
@@ -76,6 +79,7 @@ explain
 ```text
 Explanation of this Tasks code block query:
 
+  description includes \\ =>
   description includes \
 
   No grouping instructions supplied.
diff --git a/docs/Scripting/Placeholders.md b/docs/Scripting/Placeholders.md
index 90d2887d..51754c77 100644
--- a/docs/Scripting/Placeholders.md
+++ b/docs/Scripting/Placeholders.md
@@ -43,14 +43,19 @@ the results begin with the following, which demonstrates how each value inside `
 ```text
 Explanation of this Tasks code block query:
 
+  path includes {{query.file.path}} =>
   path includes some/sample/file path.md
 
+  root includes {{query.file.root}} =>
   root includes some/
 
+  folder includes {{query.file.folder}} =>
   folder includes some/sample/
 
+  filename includes {{query.file.filename}} =>
   filename includes file path.md
 
+  description includes Some Cryptic String {{! Inline comments are removed before search }} =>
   description includes Some Cryptic String 
 
   No grouping instructions supplied.
diff --git a/src/Query/Filter/BooleanField.ts b/src/Query/Filter/BooleanField.ts
index c20c9cb7..b872b497 100644
--- a/src/Query/Filter/BooleanField.ts
+++ b/src/Query/Filter/BooleanField.ts
@@ -55,7 +55,7 @@ export class BooleanField extends Field {
         if (line.length === 0) {
             return FilterOrErrorMessage.fromError(line, 'empty line');
         }
-        const preprocessed = this.preprocessExpression(line);
+        const preprocessed = BooleanField.preprocessExpression(line);
         try {
             // Convert the (preprocessed) line into a postfix logical expression
             const postfixExpression = boonParse(preprocessed);
@@ -109,7 +109,7 @@ export class BooleanField extends Field {
         }
     }
 
-    private preprocessExpression(line: string): string {
+    public static preprocessExpression(line: string): string {
         // Prepare the query to be processed by boon-js.
         // Boon doesn't process expression with spaces unless they are surrounded by quotes, so replace
         // (due today) by ("due today").
diff --git a/src/Query/Filter/Filter.ts b/src/Query/Filter/Filter.ts
index 6655b717..61dfe0ad 100644
--- a/src/Query/Filter/Filter.ts
+++ b/src/Query/Filter/Filter.ts
@@ -1,6 +1,7 @@
 import type { Task } from '../../Task/Task';
 import type { Explanation } from '../Explain/Explanation';
 import type { SearchInfo } from '../SearchInfo';
+import { Statement } from '../Statement';
 
 /**
  * A filtering function, that takes a Task object and returns
@@ -14,31 +15,53 @@ export type FilterFunction = (task: Task, searchInfo: SearchInfo) => boolean;
 /**
  * A class that represents a parsed filtering instruction from a tasks code block.
  *
- * Currently it provides access to:
+ * It provides access to:
  *
- * - The original {@link instruction}
+ * - The original {@link instruction}, after processing of continuation lines and placeholders.
+ * - An {@link explanation}, showing how the instruction was interpreted.
+ * - A {@link statement}, which is a {@link Statement} object that gives access to the original text,
+ *   for filters that were created by a {@link Query}.
  * - The {@link filterFunction} - a {@link FilterFunction} which tests whether a task matches the filter
- *
- * Later, the plan is to add a human-readable explanation of the filter.
  */
 export class Filter {
-    readonly instruction: string;
+    /** _statement may be updated later with {@link setStatement} */
+    private _statement: Statement;
+
     readonly explanation: Explanation;
     public filterFunction: FilterFunction;
 
     public constructor(instruction: string, filterFunction: FilterFunction, explanation: Explanation) {
-        this.instruction = instruction;
+        this._statement = new Statement(instruction, instruction);
         this.explanation = explanation;
         this.filterFunction = filterFunction;
     }
 
+    public get statement(): Statement {
+        return this._statement;
+    }
+
+    /**
+     * Optionally record more detail about the source statement.
+     *
+     * In tests, we only care about the actual instruction being parsed and executed.
+     * However, in {@link Query}, we want the ability to show user more information.
+     */
+    public setStatement(statement: Statement) {
+        this._statement = statement;
+    }
+
+    public get instruction(): string {
+        return this._statement.anyPlaceholdersExpanded;
+    }
+
     public explainFilterIndented(indent: string) {
         const explanation = this.explanation;
         const unindentedExplanation = explanation.asString();
+        const explainedStatement = this._statement.explainStatement(indent);
         if (unindentedExplanation === this.instruction) {
-            return `${indent}${this.instruction}\n`;
+            return `${explainedStatement}\n`;
         } else {
-            return `${indent}${this.instruction} =>\n${explanation.asString(indent + '  ')}\n`;
+            return `${explainedStatement} =>\n${explanation.asString(indent + '  ')}\n`;
         }
     }
 }
diff --git a/src/Query/Query.ts b/src/Query/Query.ts
index be77e48a..85e95d2d 100644
--- a/src/Query/Query.ts
+++ b/src/Query/Query.ts
@@ -13,10 +13,11 @@ import * as FilterParser from './FilterParser';
 import type { Grouper } from './Group/Grouper';
 import { TaskGroups } from './Group/TaskGroups';
 import { QueryResult } from './QueryResult';
-import { scan } from './Scanner';
+import { continueLines } from './Scanner';
 import { SearchInfo } from './SearchInfo';
 import { Sort } from './Sort/Sort';
 import type { Sorter } from './Sort/Sorter';
+import type { Statement } from './Statement';
 
 export class Query implements IQuery {
     /** Note: source is the raw source, before expanding any placeholders */
@@ -56,15 +57,15 @@ export class Query implements IQuery {
 
         this.debug(`Creating query: ${this.formatQueryForLogging()}`);
 
-        scan(source).forEach((rawLine: string) => {
-            const line = this.expandPlaceholders(rawLine, path);
+        continueLines(source).forEach((statement: Statement) => {
+            const line = this.expandPlaceholders(statement, path);
             if (this.error !== undefined) {
                 // There was an error expanding placeholders.
                 return;
             }
 
             try {
-                this.parseLine(line);
+                this.parseLine(line, statement);
             } catch (e) {
                 let message;
                 if (e instanceof Error) {
@@ -79,7 +80,7 @@ export class Query implements IQuery {
         });
     }
 
-    private parseLine(line: string) {
+    private parseLine(line: string, statement: Statement) {
         switch (true) {
             case this.shortModeRegexp.test(line):
                 this._queryLayoutOptions.shortMode = true;
@@ -106,7 +107,7 @@ export class Query implements IQuery {
             case this.commentRegexp.test(line):
                 // Comment lines are ignored
                 break;
-            case this.parseFilter(line):
+            case this.parseFilter(line, statement):
                 break;
             default:
                 this.setError('do not understand query', line);
@@ -117,7 +118,8 @@ export class Query implements IQuery {
         return `[${this.source.split('\n').join(' ; ')}]`;
     }
 
-    private expandPlaceholders(source: string, path: string | undefined) {
+    private expandPlaceholders(statement: Statement, path: string | undefined) {
+        const source = statement.anyContinuationLinesRemoved;
         if (source.includes('{{') && source.includes('}}')) {
             if (this.filePath === undefined) {
                 this._error = `The query looks like it contains a placeholder, with "{{" and "}}"
@@ -145,6 +147,9 @@ ${source}`;
                 return source;
             }
         }
+
+        // Save any expanded text back in to the statement:
+        statement.recordExpandedPlaceholders(expandedSource);
         return expandedSource;
     }
 
@@ -327,10 +332,14 @@ Problem line: "${line}"`;
         }
     }
 
-    private parseFilter(line: string) {
+    private parseFilter(line: string, statement: Statement) {
         const filterOrError = FilterParser.parseFilter(line);
         if (filterOrError != null) {
             if (filterOrError.filter) {
+                // Overwrite the filter's statement, to preserve details of any
+                // continuation lines and placeholder expansions.
+                filterOrError.filter.setStatement(statement);
+
                 this._filters.push(filterOrError.filter);
             } else {
                 this.setError(filterOrError.error ?? 'Unknown error', line);
diff --git a/src/Query/Scanner.ts b/src/Query/Scanner.ts
index fd46084e..582fe4c3 100644
--- a/src/Query/Scanner.ts
+++ b/src/Query/Scanner.ts
@@ -1,3 +1,5 @@
+import { Statement } from './Statement';
+
 function endsWith1Slash(inputLine: string) {
     return inputLine.endsWith('\\');
 }
@@ -34,27 +36,48 @@ function adjustLine(inputLine: string, continuePreviousLine: boolean) {
     return adjustedLine;
 }
 
-function saveLine(outputLines: string[], continuePreviousLine: boolean, adjustedLine: string) {
-    if (continuePreviousLine) {
-        outputLines[outputLines.length - 1] += ' ' + adjustedLine;
-    } else {
-        outputLines.push(adjustedLine);
-    }
+/**
+ * Removes newlines escaped by a backslash.
+ * A trailing backslash at the end of a line can be escaped by doubling it.
+ *
+ * @param input input string
+ * @returns modified input, as a string
+ *
+ * @see continueLines
+ */
+export function continueLinesFlattened(input: string): string {
+    return continueLines(input)
+        .map((instruction) => instruction.anyContinuationLinesRemoved)
+        .join('\n');
 }
 
 /**
  * Removes newlines escaped by a backslash.
  * A trailing backslash at the end of a line can be escaped by doubling it.
  *
+ * Instruction lines are not trimmed.
+ * But instructions that are empty or only contain whitespace are discarded.
+ *
  * @param input input string
- * @returns modified input
+ * @returns modified input, as a list of strings
+ *
+ * @see continueLinesFlattened
  */
-export function continue_lines(input: string): string {
-    const outputLines: string[] = [];
+export function continueLines(input: string): Statement[] {
+    const instructions: Statement[] = [];
     let continuePreviousLine = false;
+
+    let currentStatementRaw = '';
+    let currentStatementProcessed = '';
     for (const inputLine of input.split('\n')) {
         const adjustedLine = adjustLine(inputLine, continuePreviousLine);
-        saveLine(outputLines, continuePreviousLine, adjustedLine);
+        if (continuePreviousLine) {
+            currentStatementRaw += '\n' + inputLine;
+            currentStatementProcessed += ' ' + adjustedLine;
+        } else {
+            currentStatementRaw = inputLine;
+            currentStatementProcessed = adjustedLine;
+        }
 
         // Decide what to do with the next line:
         if (endsWith2Slashes(inputLine)) {
@@ -62,8 +85,15 @@ export function continue_lines(input: string): string {
         } else {
             continuePreviousLine = endsWith1Slash(inputLine);
         }
+        if (!continuePreviousLine) {
+            if (currentStatementProcessed.trim() !== '') {
+                instructions.push(new Statement(currentStatementRaw, currentStatementProcessed));
+            }
+            currentStatementRaw = '';
+            currentStatementProcessed = '';
+        }
     }
-    return outputLines.join('\n');
+    return instructions;
 }
 
 /**
@@ -76,8 +106,5 @@ export function continue_lines(input: string): string {
  * @returns List of statements
  */
 export function scan(input: string): string[] {
-    return continue_lines(input)
-        .split('\n')
-        .map((rawLine: string) => rawLine.trim())
-        .filter((line) => line !== '');
+    return continueLines(input).map((instruction: Statement) => instruction.anyContinuationLinesRemoved);
 }
diff --git a/src/Query/Statement.ts b/src/Query/Statement.ts
new file mode 100644
index 00000000..027861b8
--- /dev/null
+++ b/src/Query/Statement.ts
@@ -0,0 +1,84 @@
+/**
+ * A single logical input statement in a Tasks Query block.
+ *
+ * This may represent multiple lines with continuation characters.
+ *
+ * - {@link anyContinuationLinesRemoved} is the statement after continuation lines have been applied.
+ *                                       It may contain placeholders, however.
+ * - If continuation lines were used, {@link rawInstruction} represents the multi-line input.
+ * - {@link anyPlaceholdersExpanded} will differ from {@link anyContinuationLinesRemoved} if there were any placeholders.
+ *
+ * In tests, generally all 3 fields are identical, as the continuation characters and placeholders
+ * are only applied in {@link Query}.
+ */
+export class Statement {
+    private readonly _rawInstruction: string;
+    private readonly _anyContinuationLinesRemoved: string;
+    private _anyPlaceholdersExpanded: string; // May be updated in recordExpandedPlaceholders() after construction
+
+    /**
+     *
+     * @param rawInstruction - If the query used continuation lines for this statement, rawInstruction represents the multi-line input.
+     * @param instruction - whitespace is trimmed
+     */
+    constructor(rawInstruction: string, instruction: string) {
+        this._rawInstruction = rawInstruction;
+        this._anyContinuationLinesRemoved = instruction.trim();
+        this._anyPlaceholdersExpanded = this._anyContinuationLinesRemoved;
+    }
+
+    public recordExpandedPlaceholders(line: string) {
+        this._anyPlaceholdersExpanded = line;
+    }
+
+    /**
+     * The original text of this statement, with all original whitespace.
+     *
+     * If there were continuation lines in the query, this will contain newline characters.
+     */
+    public get rawInstruction(): string {
+        return this._rawInstruction;
+    }
+
+    /**
+     * The text of this statement after any continuation lines have been removed.
+     *
+     * Currently, the line is trimmed, although that may change.
+     */
+    public get anyContinuationLinesRemoved(): string {
+        return this._anyContinuationLinesRemoved;
+    }
+
+    public get anyPlaceholdersExpanded(): string {
+        return this._anyPlaceholdersExpanded;
+    }
+
+    public explainStatement(indent: string) {
+        function appendLineIfDifferent(previousLine: string, nextLine: string) {
+            if (nextLine !== previousLine) {
+                result += ` =>
+${indent}${nextLine}`;
+            }
+        }
+
+        // The raw instruction is stored with any surrounding whitespace from the query line.
+        // However, this looks messy in explanations, and leads to complications when comparing
+        // whether the different fields in this class are identical.
+        // So for simplicity, we trim the raw instruction in 'explain' output.
+        const rawInstructionTrimmed = this._rawInstruction.trim();
+
+        const raw = rawInstructionTrimmed.split('\n').join('\n' + indent);
+        let result = `${indent}${raw}`;
+
+        // If the raw instruction has more than one line, append text so that the
+        // subsequent '=>' string will appear at the start of the next line, for clarity:
+        if (this._rawInstruction.includes('\n')) {
+            result += '\n' + indent;
+        }
+
+        appendLineIfDifferent(rawInstructionTrimmed, this._anyContinuationLinesRemoved);
+        appendLineIfDifferent(this._anyContinuationLinesRemoved, this._anyPlaceholdersExpanded);
+
+        return result;
+    }
+}
diff --git a/src/lib/DateTools.ts b/src/lib/DateTools.ts
index 32fca914..fa9b61b9 100644
--- a/src/lib/DateTools.ts
+++ b/src/lib/DateTools.ts
@@ -1,3 +1,5 @@
+import * as chrono from 'chrono-node';
+
 export function compareByDate(a: moment.Moment | null, b: moment.Moment | null): -1 | 0 | 1 {
     if (a !== null && b === null) {
         return -1;
@@ -23,3 +25,71 @@ export function compareByDate(a: moment.Moment | null, b: moment.Moment | null):
         return 0;
     }
 }
+
+/*
+    MAINTENANCE NOTE on these Date functions:
+        Repetitious date-related code in this file has been extracted
+        out in to several parseTypedDateFor....() functions over time.
+
+        There is some similarity between these functions, and also
+        some subtle differences.
+
+        Future refactoring to simplify them would be welcomed.
+
+        When editing of Done date is introduced, the functions
+        parseTypedDateForDisplayUsingFutureDate() and parseTypedDateForDisplay()
+        may collapse in to a single case.
+*/
+
+/**
+ * Parse and return the entered value for a date field.
+ * @param fieldName
+ * @param typedDate - what the user has entered, such as '2023-01-23' or 'tomorrow'
+ * @param forwardDate
+ * @returns the parsed date string. Includes "invalid" if {@code typedDate} was invalid.
+ */
+function parseTypedDateForDisplay(
+    fieldName: 'created' | 'start' | 'scheduled' | 'due' | 'done' | 'cancelled',
+    typedDate: string,
+    forwardDate: Date | undefined = undefined,
+): string {
+    if (!typedDate) {
+        return `<i>no ${fieldName} date</i>`;
+    }
+    const parsed = chrono.parseDate(typedDate, forwardDate, {
+        forwardDate: forwardDate != undefined,
+    });
+    if (parsed !== null) {
+        return window.moment(parsed).format('YYYY-MM-DD');
+    }
+    return `<i>invalid ${fieldName} date</i>`;
+}
+
+/**
+ * Like {@link parseTypedDateForDisplay} but also accounts for the 'Only future dates' setting.
+ * @param fieldName
+ * @param typedDate - what the user has entered, such as '2023-01-23' or 'tomorrow'
+ * @returns the parsed date string. Includes "invalid" if {@code typedDate} was invalid.
+ * @param forwardOnly
+ */
+export function parseTypedDateForDisplayUsingFutureDate(
+    fieldName: 'start' | 'scheduled' | 'due' | 'done' | 'created' | 'cancelled',
+    typedDate: string,
+    forwardOnly: boolean,
+): string {
+    return parseTypedDateForDisplay(fieldName, typedDate, forwardOnly ? new Date() : undefined);
+}
+
+/**
+ * Read the entered value for a date field, and return the value to be saved in the edited task.
+ * @param typedDate - what the user has entered, such as '2023-01-23' or 'tomorrow'
+ * @param forwardDate
+ */
+export function parseTypedDateForSaving(typedDate: string, forwardDate: boolean): moment.Moment | null {
+    let date: moment.Moment | null = null;
+    const parsedDate = chrono.parseDate(typedDate, new Date(), { forwardDate });
+    if (parsedDate !== null) {
+        date = window.moment(parsedDate);
+    }
+    return date;
+}
diff --git a/src/ui/EditTask.svelte b/src/ui/EditTask.svelte
index 95a03d16..d85f01cb 100644
--- a/src/ui/EditTask.svelte
+++ b/src/ui/EditTask.svelte
@@ -1,6 +1,6 @@
 <script lang="ts">
-    import * as chrono from 'chrono-node';
     import { onMount } from 'svelte';
+    import { parseTypedDateForDisplayUsingFutureDate, parseTypedDateForSaving } from '../lib/DateTools';
     import { Recurrence } from '../Task/Recurrence';
     import { getSettings, TASK_FORMATS } from '../Config/Settings';
     import { GlobalFilter } from '../Config/GlobalFilter';
@@ -131,76 +131,6 @@
             accessKeyIndex: 1
         }]
 
-    /*
-        MAINTENANCE NOTE on these Date functions:
-            Repetitious date-related code in this file has been extracted
-            out in to several parseTypedDateFor....() functions over time.
-
-            There is some similarity between these functions, and also
-            some subtle differences.
-
-            Future refactoring to simplify them would be welcomed.
-
-            When editing of Done date is introduced, the functions
-            parseTypedDateForDisplayUsingFutureDate() and parseTypedDateForDisplay()
-            may collapse in to a single case.
-     */
-
-    /**
-     * Parse and return the entered value for a date field.
-     * @param fieldName
-     * @param typedDate - what the user has entered, such as '2023-01-23' or 'tomorrow'
-     * @param forwardDate
-     * @returns the parsed date string. Includes "invalid" if {@code typedDate} was invalid.
-     */
-    function parseTypedDateForDisplay(
-        fieldName: 'created' | 'start' | 'scheduled' | 'due' | 'done' | 'cancelled',
-        typedDate: string,
-        forwardDate: Date | undefined = undefined,
-    ): string {
-        if (!typedDate) {
-            return `<i>no ${fieldName} date</i>`;
-        }
-        const parsed = chrono.parseDate(typedDate, forwardDate, {
-            forwardDate: forwardDate != undefined,
-        });
-        if (parsed !== null) {
-            return window.moment(parsed).format('YYYY-MM-DD');
-        }
-        return `<i>invalid ${fieldName} date</i>`;
-    }
-
-    /**
-     * Like {@link parseTypedDateForDisplay} but also accounts for the 'Only future dates' setting.
-     * @param fieldName
-     * @param typedDate - what the user has entered, such as '2023-01-23' or 'tomorrow'
-     * @returns the parsed date string. Includes "invalid" if {@code typedDate} was invalid.
-     */
-    function parseTypedDateForDisplayUsingFutureDate(fieldName: 'start' | 'scheduled' | 'due' | 'done' | 'created' | 'cancelled', typedDate: string): string {
-        return parseTypedDateForDisplay(
-            fieldName,
-            typedDate,
-            editableTask.forwardOnly ? new Date() : undefined,
-        );
-    }
-
-    /**
-     * Read the entered value for a date field, and return the value to be saved in the edited task.
-     * @param typedDate - what the user has entered, such as '2023-01-23' or 'tomorrow'
-     */
-    function parseTypedDateForSaving(typedDate: string): moment.Moment | null {
-        let date: moment.Moment | null = null;
-        const parsedDate = chrono.parseDate(
-            typedDate,
-            new Date(),
-            { forwardDate: editableTask.forwardOnly },
-        );
-        if (parsedDate !== null) {
-            date = window.moment(parsedDate);
-        }
-        return date;
-    }
-
     async function serialiseTaskId(task: Task) {
         if (task.id !== "") return task;
 
@@ -241,37 +171,37 @@
     // NEW_TASK_FIELD_EDIT_REQUIRED
     $: {
         editableTask.startDate = doAutocomplete(editableTask.startDate);
-        parsedStartDate = parseTypedDateForDisplayUsingFutureDate('start', editableTask.startDate);
+        parsedStartDate = parseTypedDateForDisplayUsingFutureDate('start', editableTask.startDate, editableTask.forwardOnly);
         isStartDateValid = !parsedStartDate.includes('invalid');
     }
 
     $: {
         editableTask.scheduledDate = doAutocomplete(editableTask.scheduledDate);
-        parsedScheduledDate = parseTypedDateForDisplayUsingFutureDate('scheduled', editableTask.scheduledDate);
+        parsedScheduledDate = parseTypedDateForDisplayUsingFutureDate('scheduled', editableTask.scheduledDate, editableTask.forwardOnly);
         isScheduledDateValid = !parsedScheduledDate.includes('invalid');
     }
 
     $: {
         editableTask.dueDate = doAutocomplete(editableTask.dueDate);
-        parsedDueDate = parseTypedDateForDisplayUsingFutureDate('due', editableTask.dueDate);
+        parsedDueDate = parseTypedDateForDisplayUsingFutureDate('due', editableTask.dueDate, editableTask.forwardOnly);
         isDueDateValid = !parsedDueDate.includes('invalid');
     }
 
     $: {
         editableTask.doneDate = doAutocomplete(editableTask.doneDate);
-        parsedDoneDate = parseTypedDateForDisplayUsingFutureDate('done', editableTask.doneDate);
+        parsedDoneDate = parseTypedDateForDisplayUsingFutureDate('done', editableTask.doneDate, editableTask.forwardOnly);
         isDoneDateValid = !parsedDoneDate.includes('invalid');
     }
 
     $: {
         editableTask.createdDate = doAutocomplete(editableTask.createdDate);
-        parsedCreatedDate = parseTypedDateForDisplayUsingFutureDate('created', editableTask.createdDate);
+        parsedCreatedDate = parseTypedDateForDisplayUsingFutureDate('created', editableTask.createdDate, editableTask.forwardOnly);
         isCreatedDateValid = !parsedCreatedDate.includes('invalid');
     }
 
     $: {
         editableTask.cancelledDate = doAutocomplete(editableTask.cancelledDate);
-        parsedCancelledDate = parseTypedDateForDisplayUsingFutureDate('cancelled', editableTask.cancelledDate);
+        parsedCancelledDate = parseTypedDateForDisplayUsingFutureDate('cancelled', editableTask.cancelledDate, editableTask.forwardOnly);
         isCancelledDateValid = !parsedCancelledDate.includes('invalid');
     }
 
@@ -393,13 +323,13 @@
             description = GlobalFilter.getInstance().prependTo(description);
         }
 
-        const startDate = parseTypedDateForSaving(editableTask.startDate);
-        const scheduledDate = parseTypedDateForSaving(editableTask.scheduledDate);
-        const dueDate = parseTypedDateForSaving(editableTask.dueDate);
+        const startDate = parseTypedDateForSaving(editableTask.startDate, editableTask.forwardOnly);
+        const scheduledDate = parseTypedDateForSaving(editableTask.scheduledDate, editableTask.forwardOnly);
+        const dueDate = parseTypedDateForSaving(editableTask.dueDate, editableTask.forwardOnly);
 
-        const cancelledDate = parseTypedDateForSaving(editableTask.cancelledDate);
-        const createdDate = parseTypedDateForSaving(editableTask.createdDate);
-        const doneDate = parseTypedDateForSaving(editableTask.doneDate);
+        const cancelledDate = parseTypedDateForSaving(editableTask.cancelledDate, editableTask.forwardOnly);
+        const createdDate = parseTypedDateForSaving(editableTask.createdDate, editableTask.forwardOnly);
+        const doneDate = parseTypedDateForSaving(editableTask.doneDate, editableTask.forwardOnly);
 
         let recurrence: Recurrence | null = null;
         if (editableTask.recurrenceRule) {


Test patch:
diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.explain_line_continuation_-_double_slash.approved.explanation.text b/tests/Query/Explain/DocsSamplesForExplain.test.explain_line_continuation_-_double_slash.approved.explanation.text
index 126f066e..41cd1adf 100644
--- a/tests/Query/Explain/DocsSamplesForExplain.test.explain_line_continuation_-_double_slash.approved.explanation.text
+++ b/tests/Query/Explain/DocsSamplesForExplain.test.explain_line_continuation_-_double_slash.approved.explanation.text
@@ -1,5 +1,6 @@
 Explanation of this Tasks code block query:
 
+  description includes \\ =>
   description includes \
 
   No grouping instructions supplied.
diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.explain_line_continuation_-_single_slash.approved.explanation.text b/tests/Query/Explain/DocsSamplesForExplain.test.explain_line_continuation_-_single_slash.approved.explanation.text
index 0d5440cf..4eed308e 100644
--- a/tests/Query/Explain/DocsSamplesForExplain.test.explain_line_continuation_-_single_slash.approved.explanation.text
+++ b/tests/Query/Explain/DocsSamplesForExplain.test.explain_line_continuation_-_single_slash.approved.explanation.text
@@ -1,5 +1,8 @@
 Explanation of this Tasks code block query:
 
+  (priority is highest) OR       \
+      (priority is lowest)
+   =>
   (priority is highest) OR (priority is lowest) =>
     OR (At least one of):
       priority is highest
diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.explain_nested_boolean_combinations.approved.explanation.text b/tests/Query/Explain/DocsSamplesForExplain.test.explain_nested_boolean_combinations.approved.explanation.text
index 010d0e9f..65d969d9 100644
--- a/tests/Query/Explain/DocsSamplesForExplain.test.explain_nested_boolean_combinations.approved.explanation.text
+++ b/tests/Query/Explain/DocsSamplesForExplain.test.explain_nested_boolean_combinations.approved.explanation.text
@@ -1,5 +1,12 @@
 Explanation of this Tasks code block query:
 
+  (                                                                                       \
+      (description includes 1) AND (description includes 2) AND (description includes 3)  \
+  ) OR (                                                                                  \
+      (description includes 5) AND (description includes 6) AND (description includes 7)  \
+  )                                                                                       \
+  AND NOT (description includes 7)
+   =>
   ( (description includes 1) AND (description includes 2) AND (description includes 3) ) OR ( (description includes 5) AND (description includes 6) AND (description includes 7) ) AND NOT (description includes 7) =>
     OR (At least one of):
       AND (All of):
diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text
index 5565aa0d..efb69fe9 100644
--- a/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text
+++ b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text
@@ -1,13 +1,18 @@
 Explanation of this Tasks code block query:
 
+  path includes {{query.file.path}} =>
   path includes some/sample/file path.md
 
+  root includes {{query.file.root}} =>
   root includes some/
 
+  folder includes {{query.file.folder}} =>
   folder includes some/sample/
 
+  filename includes {{query.file.filename}} =>
   filename includes file path.md
 
+  description includes Some Cryptic String {{! Inline comments are removed before search }} =>
   description includes Some Cryptic String 
 
   No grouping instructions supplied.
diff --git a/tests/Query/Explain/Explainer.test.ts b/tests/Query/Explain/Explainer.test.ts
index d7a4921d..5fd30adf 100644
--- a/tests/Query/Explain/Explainer.test.ts
+++ b/tests/Query/Explain/Explainer.test.ts
@@ -12,6 +12,17 @@ import { DebugSettings } from '../../../src/Config/DebugSettings';
 
 window.moment = moment;
 
+/**
+ * Join all the lines with continuation character, then create query
+ * @param lines
+ */
+function makeQueryFromContinuationLines(lines: string[]) {
+    const source = lines.join('\\\n');
+    const query = new Query(source, 'sample.md');
+    expect(query.error).toBeUndefined();
+    return query;
+}
+
 afterEach(() => {
     GlobalFilter.getInstance().reset();
     resetSettings();
@@ -137,6 +148,27 @@ describe('explain filters', () => {
             "
         `);
     });
+
+    describe('white space around instruction', () => {
+        it('should not duplicate query line with extra space - 1-line explanation', () => {
+            const source = '            has done date    ';
+            const query = new Query(source);
+            expect(explainer.explainFilters(query)).toMatchInlineSnapshot(`
+                "has done date
+                "
+            `);
+        });
+
+        it('should not duplicate query line with extra space - multi-line explanation', () => {
+            const source = '            description regex matches /(buy|order)/i    ';
+            const query = new Query(source);
+            expect(explainer.explainFilters(query)).toMatchInlineSnapshot(`
+                "description regex matches /(buy|order)/i =>
+                  using regex:            '(buy|order)' with flag 'i'
+                "
+            `);
+        });
+    });
 });
 
 describe('explain groupers', () => {
@@ -150,6 +182,28 @@ describe('explain groupers', () => {
             "
         `);
     });
+
+    it('should explain a multi-line "group by function"', () => {
+        const lines = [
+            'group by function                                   ',
+            '    const date = task.due;                          ',
+            '    if (!date.moment) {                             ',
+            '        return "Undated";                           ',
+            '    }                                               ',
+            '    if (date.moment.day() === 0) {                  ',
+            '        {{! Put the Sunday group last: }}           ',
+            '        return date.format("[%%][8][%%]dddd");      ',
+            '    }                                               ',
+            '    return date.format("[%%]d[%%]dddd");',
+        ];
+        const query = makeQueryFromContinuationLines(lines);
+
+        // TODO Make this also show the original instruction, including continuations. See #2349.
+        expect(explainer.explainGroups(query)).toMatchInlineSnapshot(`
+            "group by function const date = task.due; if (!date.moment) { return "Undated"; } if (date.moment.day() === 0) {  return date.format("[%%][8][%%]dddd"); } return date.format("[%%]d[%%]dddd");
+            "
+        `);
+    });
 });
 
 describe('explain sorters', () => {
@@ -162,6 +216,24 @@ describe('explain sorters', () => {
             "
         `);
     });
+
+    it('should explain a multi-line "sort by function"', () => {
+        const lines = [
+            'sort by function                                                   ',
+            '    const priorities = [..."🟥🟧🟨🟩🟦"];                        ',
+            '    for (let i = 0; i < priorities.length; i++) {                  ',
+            '        if (task.description.includes(priorities[i])) return i;    ',
+            '    }                                                              ',
+            '    return 999;',
+        ];
+        const query = makeQueryFromContinuationLines(lines);
+
+        // TODO Make this also show the original instruction, including continuations. See #2349.
+        expect(explainer.explainSorters(query)).toMatchInlineSnapshot(`
+            "sort by function const priorities = [..."🟥🟧🟨🟩🟦"]; for (let i = 0; i < priorities.length; i++) { if (task.description.includes(priorities[i])) return i; } return 999;
+            "
+        `);
+    });
 });
 
 describe('explain limits', () => {
diff --git a/tests/Query/Filter/BooleanField.test.boolean_query_-_exhaustive_tests_explain.approved.txt b/tests/Query/Filter/BooleanField.test.boolean_query_-_exhaustive_tests_explain.approved.txt
new file mode 100644
index 00000000..76630e96
--- /dev/null
+++ b/tests/Query/Filter/BooleanField.test.boolean_query_-_exhaustive_tests_explain.approved.txt
@@ -0,0 +1,463 @@
+Results of parsing and explaining boolean expressions
+
+
+
+
+Input:
+'"description includes d1" AND "description includes d2"'
+=>
+Result:
+  "description includes d1" AND "description includes d2" =>
+    AND (All of):
+      description includes d1
+      description includes d2
+
+
+--------------------------------------------------------
+
+
+Input:
+'( (description includes a) AND (description includes b) ) AND (description includes c)'
+=>
+Result:
+  ( (description includes a) AND (description includes b) ) AND (description includes c) =>
+    AND (All of):
+      description includes a
+      description includes b
+      description includes c
+
+
+--------------------------------------------------------
+
+
+Input:
+'( (description includes a) AND (description includes b) AND (description includes c) ) OR ( (description includes d) AND (description includes e) AND (description includes f) )'
+=>
+Result:
+  ( (description includes a) AND (description includes b) AND (description includes c) ) OR ( (description includes d) AND (description includes e) AND (description includes f) ) =>
+    OR (At least one of):
+      AND (All of):
+        description includes a
+        description includes b
+        description includes c
+      AND (All of):
+        description includes d
+        description includes e
+        description includes f
+
+
+--------------------------------------------------------
+
+
+Input:
+'( (description includes a) OR (description includes b) ) OR (description includes c)'
+=>
+Result:
+  ( (description includes a) OR (description includes b) ) OR (description includes c) =>
+    OR (At least one of):
+      description includes a
+      description includes b
+      description includes c
+
+
+--------------------------------------------------------
+
+
+Input:
+'( (description includes a) OR (description includes b) OR (description includes c) ) AND ( (description includes d) OR (description includes e) OR (description includes f) )'
+=>
+Result:
+  ( (description includes a) OR (description includes b) OR (description includes c) ) AND ( (description includes d) OR (description includes e) OR (description includes f) ) =>
+    AND (All of):
+      OR (At least one of):
+        description includes a
+        description includes b
+        description includes c
+      OR (At least one of):
+        description includes d
+        description includes e
+        description includes f
+
+
+--------------------------------------------------------
+
+
+Input:
+'( (filter by function task.description.includes('a')) OR (filter by function task.description.includes('b')) OR (filter by function task.description.includes('c')) ) AND ( (filter by function task.description.includes('d')) OR (filter by function task.description.includes('e')) OR (filter by function task.description.includes('f')) )'
+=>
+Result:
+malformed boolean query -- Invalid token (check the documentation for guidelines)
+
+--------------------------------------------------------
+
+
+Input:
+'( description includes a ) AND ( (description includes b) AND (description includes c) )'
+=>
+Result:
+  ( description includes a ) AND ( (description includes b) AND (description includes c) ) =>
+    AND (All of):
+      description includes a
+      AND (All of):
+        description includes b
+        description includes c
+
+
+--------------------------------------------------------
+
+
+Input:
+'( description includes a ) OR ( (description includes b) OR (description includes c) )'
+=>
+Result:
+  ( description includes a ) OR ( (description includes b) OR (description includes c) ) =>
+    OR (At least one of):
+      description includes a
+      OR (At least one of):
+        description includes b
+        description includes c
+
+
+--------------------------------------------------------
+
+
+Input:
+'(((((description includes #context/location1)))))'
+=>
+Result:
+  (((((description includes #context/location1))))) =>
+    description includes #context/location1
+
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes #context/location1)'
+=>
+Result:
+  (description includes #context/location1) =>
+    description includes #context/location1
+
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes #context/location1) OR (description includes #context/location2 ) OR (  description includes #context/location3 ) OR   (  description includes #context/location4 )'
+=>
+Result:
+  (description includes #context/location1) OR (description includes #context/location2 ) OR (  description includes #context/location3 ) OR   (  description includes #context/location4 ) =>
+    OR (At least one of):
+      description includes #context/location1
+      description includes #context/location2
+      description includes #context/location3
+      description includes #context/location4
+
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes 1) AND (description includes 2) AND (description includes 3)'
+=>
+Result:
+  (description includes 1) AND (description includes 2) AND (description includes 3) =>
+    AND (All of):
+      description includes 1
+      description includes 2
+      description includes 3
+
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes 1) AND (description includes 2) AND (description includes 3) AND (description includes 4) AND (description includes 5) AND (description includes 6) AND (description includes 7) AND (description includes 8) AND (description includes 9)'
+=>
+Result:
+  (description includes 1) AND (description includes 2) AND (description includes 3) AND (description includes 4) AND (description includes 5) AND (description includes 6) AND (description includes 7) AND (description includes 8) AND (description includes 9) =>
+    AND (All of):
+      description includes 1
+      description includes 2
+      description includes 3
+      description includes 4
+      description includes 5
+      description includes 6
+      description includes 7
+      description includes 8
+      description includes 9
+
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes 1)   AND   (description includes 2)   AND   (description includes 3)   AND   (description includes 4)   AND   (description includes 5)   AND   (description includes 6)   AND   (description includes 7)   AND   (description includes 8)   AND   (description includes 9)'
+=>
+Result:
+  (description includes 1)   AND   (description includes 2)   AND   (description includes 3)   AND   (description includes 4)   AND   (description includes 5)   AND   (description includes 6)   AND   (description includes 7)   AND   (description includes 8)   AND   (description includes 9) =>
+    AND (All of):
+      description includes 1
+      description includes 2
+      description includes 3
+      description includes 4
+      description includes 5
+      description includes 6
+      description includes 7
+      description includes 8
+      description includes 9
+
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) AND (priority medium)'
+=>
+Result:
+  (description includes d1) AND (priority medium) =>
+    AND (All of):
+      description includes d1
+      priority is medium
+
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) AND NOT (description includes d2)'
+=>
+Result:
+  (description includes d1) AND NOT (description includes d2) =>
+    AND (All of):
+      description includes d1
+      NOT:
+        description includes d2
+
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) AND NOT (priority medium)'
+=>
+Result:
+  (description includes d1) AND NOT (priority medium) =>
+    AND (All of):
+      description includes d1
+      NOT:
+        priority is medium
+
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) OR (description includes d2)'
+=>
+Result:
+  (description includes d1) OR (description includes d2) =>
+    OR (At least one of):
+      description includes d1
+      description includes d2
+
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) OR (description includes d2) OR (priority medium)'
+=>
+Result:
+  (description includes d1) OR (description includes d2) OR (priority medium) =>
+    OR (At least one of):
+      description includes d1
+      description includes d2
+      priority is medium
+
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) OR (priority medium)'
+=>
+Result:
+  (description includes d1) OR (priority medium) =>
+    OR (At least one of):
+      description includes d1
+      priority is medium
+
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) OR NOT (description includes d2)'
+=>
+Result:
+  (description includes d1) OR NOT (description includes d2) =>
+    OR (At least one of):
+      description includes d1
+      NOT:
+        description includes d2
+
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) OR NOT (priority medium)'
+=>
+Result:
+  (description includes d1) OR NOT (priority medium) =>
+    OR (At least one of):
+      description includes d1
+      NOT:
+        priority is medium
+
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) XOR (description includes d2)'
+=>
+Result:
+  (description includes d1) XOR (description includes d2) =>
+    XOR (Exactly one of):
+      description includes d1
+      description includes d2
+
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) XOR (priority medium)'
+=>
+Result:
+  (description includes d1) XOR (priority medium) =>
+    XOR (Exactly one of):
+      description includes d1
+      priority is medium
+
+
+--------------------------------------------------------
+
+
+
+Input:
+'AND (description includes d1)'
+=>
+Result:
+malformed boolean query -- Invalid token (check the documentation for guidelines)
+
+--------------------------------------------------------
+
+
+Input:
+'NOT (description blahblah d1)'
+=>
+Result:
+couldn't parse sub-expression 'description blahblah d1'
+
+--------------------------------------------------------
+
+
+Input:
+'NOT (description includes d1)'
+=>
+Result:
+  NOT (description includes d1) =>
+    NOT:
+      description includes d1
+
+
+--------------------------------------------------------
+
+
+Input:
+'NOT (happens before blahblahblah)'
+=>
+Result:
+couldn't parse sub-expression 'happens before blahblahblah': do not understand happens date
+
+--------------------------------------------------------
+
+
+Input:
+'OR (description includes d1)'
+=>
+Result:
+malformed boolean query -- Invalid token (check the documentation for guidelines)
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes SHOULD NOT BE RECOGNISED AS A BOOLEAN)AND(description includes BECAUSE THERE ARE NO SPACES AROUND THE 'AND' OPERATOR)'
+=>
+Result:
+malformed boolean query -- Unexpected character: A. A closing parenthesis should be followed by another closing parenthesis or whitespace (check the documentation for guidelines)
+
+--------------------------------------------------------
+
+
+
+Input:
+'(path includes (some example) OR (path includes )some example()'
+=>
+Result:
+malformed boolean query -- Invalid token (check the documentation for guidelines)
+
+--------------------------------------------------------
+
+
+Input:
+'(path includes (some example)) OR (path includes )some example()'
+=>
+Result:
+malformed boolean query -- Invalid token (check the documentation for guidelines)
+
+--------------------------------------------------------
+
+
+Input:
+'(path includes )some example() OR (path includes (some example))'
+=>
+Result:
+malformed boolean query -- Unexpected character: s. A closing parenthesis should be followed by another closing parenthesis or whitespace (check the documentation for guidelines)
+
+--------------------------------------------------------
+
+
+Input:
+'(path includes ()some example()) OR (path includes ((some example)))'
+=>
+Result:
+malformed boolean query -- Invalid token (check the documentation for guidelines)
+
+--------------------------------------------------------
+
+
+
+Input:
+'( description regex matches /(buy|order|voucher|lakeland|purchase|\spresent)/i ) OR ( path includes Home/Shopping )'
+=>
+Result:
+malformed boolean query -- Invalid token (check the documentation for guidelines)
+
+--------------------------------------------------------
+
+
+Input:
+'( filter by function ! 'NON_TASK,CANCELLED'.includes(task.status.type) ) OR ( filter by function const date = task.due.moment; return date ? !date.isValid() : false; ) OR ( filter by function task.due.moment?.isSameOrBefore(moment(), 'day') || false ) OR ( filter by function task.urgency.toFixed(2) === 1.95.toFixed(2) ) OR ( filter by function (!task.isRecurring) && task.originalMarkdown.includes('🔁') ) OR ( filter by function task.file.path.toLocaleLowerCase() === 'TASKS RELEASES/4.1.0 RELEASE.MD'.toLocaleLowerCase() ) OR ( filter by function const taskDate = task.due.moment; const now = moment(); return taskDate?.isSame(now, 'day') || ( !taskDate && task.heading?.includes(now.format('YYYY-MM-DD')) ) || false ) OR ( filter by function const wanted = '#context/home'; return task.heading?.includes(wanted) || task.tags.find( (tag) => tag === wanted ) && true || false; )'
+=>
+Result:
+malformed boolean query -- Invalid token (check the documentation for guidelines)
+
+--------------------------------------------------------
+
+
diff --git a/tests/Query/Filter/BooleanField.test.boolean_query_-_exhaustive_tests_preprocess.approved.txt b/tests/Query/Filter/BooleanField.test.boolean_query_-_exhaustive_tests_preprocess.approved.txt
new file mode 100644
index 00000000..076bac68
--- /dev/null
+++ b/tests/Query/Filter/BooleanField.test.boolean_query_-_exhaustive_tests_preprocess.approved.txt
@@ -0,0 +1,332 @@
+Results of preprocessing boolean expressions
+
+
+
+
+Input:
+'"description includes d1" AND "description includes d2"'
+=>
+Result:
+"\"description includes d1\" AND \"description includes d2\""
+
+--------------------------------------------------------
+
+
+Input:
+'( (description includes a) AND (description includes b) ) AND (description includes c)'
+=>
+Result:
+"( (\"description includes a\") AND (\"description includes b\") ) AND (\"description includes c\")"
+
+--------------------------------------------------------
+
+
+Input:
+'( (description includes a) AND (description includes b) AND (description includes c) ) OR ( (description includes d) AND (description includes e) AND (description includes f) )'
+=>
+Result:
+"( (\"description includes a\") AND (\"description includes b\") AND (\"description includes c\") ) OR ( (\"description includes d\") AND (\"description includes e\") AND (\"description includes f\") )"
+
+--------------------------------------------------------
+
+
+Input:
+'( (description includes a) OR (description includes b) ) OR (description includes c)'
+=>
+Result:
+"( (\"description includes a\") OR (\"description includes b\") ) OR (\"description includes c\")"
+
+--------------------------------------------------------
+
+
+Input:
+'( (description includes a) OR (description includes b) OR (description includes c) ) AND ( (description includes d) OR (description includes e) OR (description includes f) )'
+=>
+Result:
+"( (\"description includes a\") OR (\"description includes b\") OR (\"description includes c\") ) AND ( (\"description includes d\") OR (\"description includes e\") OR (\"description includes f\") )"
+
+--------------------------------------------------------
+
+
+Input:
+'( (filter by function task.description.includes('a')) OR (filter by function task.description.includes('b')) OR (filter by function task.description.includes('c')) ) AND ( (filter by function task.description.includes('d')) OR (filter by function task.description.includes('e')) OR (filter by function task.description.includes('f')) )'
+=>
+Result:
+"( (filter by function task.description.includes(\"'a'\")) OR (filter by function task.description.includes(\"'b'\")) OR (filter by function task.description.includes(\"'c'\")) ) AND ( (filter by function task.description.includes(\"'d'\")) OR (filter by function task.description.includes(\"'e'\")) OR (filter by function task.description.includes(\"'f'\")) )"
+
+--------------------------------------------------------
+
+
+Input:
+'( description includes a ) AND ( (description includes b) AND (description includes c) )'
+=>
+Result:
+"(\" description includes a \") AND ( (\"description includes b\") AND (\"description includes c\") )"
+
+--------------------------------------------------------
+
+
+Input:
+'( description includes a ) OR ( (description includes b) OR (description includes c) )'
+=>
+Result:
+"(\" description includes a \") OR ( (\"description includes b\") OR (\"description includes c\") )"
+
+--------------------------------------------------------
+
+
+Input:
+'(((((description includes #context/location1)))))'
+=>
+Result:
+"(((((\"description includes #context/location1\")))))"
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes #context/location1)'
+=>
+Result:
+"(\"description includes #context/location1\")"
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes #context/location1) OR (description includes #context/location2 ) OR (  description includes #context/location3 ) OR   (  description includes #context/location4 )'
+=>
+Result:
+"(\"description includes #context/location1\") OR (\"description includes #context/location2 \") OR (\"  description includes #context/location3 \") OR   (\"  description includes #context/location4 \")"
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes 1) AND (description includes 2) AND (description includes 3)'
+=>
+Result:
+"(\"description includes 1\") AND (\"description includes 2\") AND (\"description includes 3\")"
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes 1) AND (description includes 2) AND (description includes 3) AND (description includes 4) AND (description includes 5) AND (description includes 6) AND (description includes 7) AND (description includes 8) AND (description includes 9)'
+=>
+Result:
+"(\"description includes 1\") AND (\"description includes 2\") AND (\"description includes 3\") AND (\"description includes 4\") AND (\"description includes 5\") AND (\"description includes 6\") AND (\"description includes 7\") AND (\"description includes 8\") AND (\"description includes 9\")"
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes 1)   AND   (description includes 2)   AND   (description includes 3)   AND   (description includes 4)   AND   (description includes 5)   AND   (description includes 6)   AND   (description includes 7)   AND   (description includes 8)   AND   (description includes 9)'
+=>
+Result:
+"(\"description includes 1\")   AND   (\"description includes 2\")   AND   (\"description includes 3\")   AND   (\"description includes 4\")   AND   (\"description includes 5\")   AND   (\"description includes 6\")   AND   (\"description includes 7\")   AND   (\"description includes 8\")   AND   (\"description includes 9\")"
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) AND (priority medium)'
+=>
+Result:
+"(\"description includes d1\") AND (\"priority medium\")"
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) AND NOT (description includes d2)'
+=>
+Result:
+"(\"description includes d1\") AND NOT (\"description includes d2\")"
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) AND NOT (priority medium)'
+=>
+Result:
+"(\"description includes d1\") AND NOT (\"priority medium\")"
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) OR (description includes d2)'
+=>
+Result:
+"(\"description includes d1\") OR (\"description includes d2\")"
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) OR (description includes d2) OR (priority medium)'
+=>
+Result:
+"(\"description includes d1\") OR (\"description includes d2\") OR (\"priority medium\")"
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) OR (priority medium)'
+=>
+Result:
+"(\"description includes d1\") OR (\"priority medium\")"
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) OR NOT (description includes d2)'
+=>
+Result:
+"(\"description includes d1\") OR NOT (\"description includes d2\")"
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) OR NOT (priority medium)'
+=>
+Result:
+"(\"description includes d1\") OR NOT (\"priority medium\")"
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) XOR (description includes d2)'
+=>
+Result:
+"(\"description includes d1\") XOR (\"description includes d2\")"
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes d1) XOR (priority medium)'
+=>
+Result:
+"(\"description includes d1\") XOR (\"priority medium\")"
+
+--------------------------------------------------------
+
+
+
+Input:
+'AND (description includes d1)'
+=>
+Result:
+"AND (\"description includes d1\")"
+
+--------------------------------------------------------
+
+
+Input:
+'NOT (description blahblah d1)'
+=>
+Result:
+"NOT (\"description blahblah d1\")"
+
+--------------------------------------------------------
+
+
+Input:
+'NOT (description includes d1)'
+=>
+Result:
+"NOT (\"description includes d1\")"
+
+--------------------------------------------------------
+
+
+Input:
+'NOT (happens before blahblahblah)'
+=>
+Result:
+"NOT (\"happens before blahblahblah\")"
+
+--------------------------------------------------------
+
+
+Input:
+'OR (description includes d1)'
+=>
+Result:
+"OR (\"description includes d1\")"
+
+--------------------------------------------------------
+
+
+Input:
+'(description includes SHOULD NOT BE RECOGNISED AS A BOOLEAN)AND(description includes BECAUSE THERE ARE NO SPACES AROUND THE 'AND' OPERATOR)'
+=>
+Result:
+"(\"description includes SHOULD NOT BE RECOGNISED AS A BOOLEAN\")AND(\"description includes BECAUSE THERE ARE NO SPACES AROUND THE 'AND' OPERATOR\")"
+
+--------------------------------------------------------
+
+
+
+Input:
+'(path includes (some example) OR (path includes )some example()'
+=>
+Result:
+"(path includes (\"some example\") OR (\"path includes \")some example()"
+
+--------------------------------------------------------
+
+
+Input:
+'(path includes (some example)) OR (path includes )some example()'
+=>
+Result:
+"(path includes (\"some example\")) OR (\"path includes \")some example()"
+
+--------------------------------------------------------
+
+
+Input:
+'(path includes )some example() OR (path includes (some example))'
+=>
+Result:
+"(\"path includes \")some example() OR (path includes (\"some example\"))"
+
+--------------------------------------------------------
+
+
+Input:
+'(path includes ()some example()) OR (path includes ((some example)))'
+=>
+Result:
+"(path includes ()some example()) OR (path includes ((\"some example\")))"
+
+--------------------------------------------------------
+
+
+
+Input:
+'( description regex matches /(buy|order|voucher|lakeland|purchase|\spresent)/i ) OR ( path includes Home/Shopping )'
+=>
+Result:
+"( description regex matches /(\"buy|order|voucher|lakeland|purchase|\\spresent\")/i ) OR (\" path includes Home/Shopping \")"
+
+--------------------------------------------------------
+
+
+Input:
+'( filter by function ! 'NON_TASK,CANCELLED'.includes(task.status.type) ) OR ( filter by function const date = task.due.moment; return date ? !date.isValid() : false; ) OR ( filter by function task.due.moment?.isSameOrBefore(moment(), 'day') || false ) OR ( filter by function task.urgency.toFixed(2) === 1.95.toFixed(2) ) OR ( filter by function (!task.isRecurring) && task.originalMarkdown.includes('🔁') ) OR ( filter by function task.file.path.toLocaleLowerCase() === 'TASKS RELEASES/4.1.0 RELEASE.MD'.toLocaleLowerCase() ) OR ( filter by function const taskDate = task.due.moment; const now = moment(); return taskDate?.isSame(now, 'day') || ( !taskDate && task.heading?.includes(now.format('YYYY-MM-DD')) ) || false ) OR ( filter by function const wanted = '#context/home'; return task.heading?.includes(wanted) || task.tags.find( (tag) => tag === wanted ) && true || false; )'
+=>
+Result:
+"( filter by function ! 'NON_TASK,CANCELLED'.includes(\"task.status.type\") ) OR ( filter by function const date = task.due.moment; return date ? !date.isValid() : false; ) OR ( filter by function task.due.moment?.isSameOrBefore(moment(), 'day') || false ) OR ( filter by function task.urgency.toFixed(\"2\") === 1.95.toFixed(\"2\") ) OR ( filter by function (\"!task.isRecurring\") && task.originalMarkdown.includes(\"'🔁'\") ) OR ( filter by function task.file.path.toLocaleLowerCase() === 'TASKS RELEASES/4.1.0 RELEASE.MD'.toLocaleLowerCase() ) OR ( filter by function const taskDate = task.due.moment; const now = moment(); return taskDate?.isSame(\"now, 'day'\") || ( !taskDate && task.heading?.includes(now.format(\"'YYYY-MM-DD'\")) ) || false ) OR ( filter by function const wanted = '#context/home'; return task.heading?.includes(\"wanted\") || task.tags.find( (\"tag\") => tag === wanted ) && true || false; )"
+
+--------------------------------------------------------
+
+
diff --git a/tests/Query/Filter/BooleanField.test.ts b/tests/Query/Filter/BooleanField.test.ts
index a20d513b..0655a4fb 100644
--- a/tests/Query/Filter/BooleanField.test.ts
+++ b/tests/Query/Filter/BooleanField.test.ts
@@ -6,6 +6,7 @@ import { BooleanField } from '../../../src/Query/Filter/BooleanField';
 import type { FilterOrErrorMessage } from '../../../src/Query/Filter/FilterOrErrorMessage';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { testFilter } from '../../TestingTools/FilterTestHelpers';
+import { verifyBooleanExpressionExplanation, verifyBooleanExpressionPreprocessing } from './BooleanFieldVerify';
 
 window.moment = moment;
 
@@ -122,36 +123,86 @@ describe('boolean query', () => {
     });
 
     describe('error cases - to show error messages', () => {
-        it('empty line', () => {
-            const filter = new BooleanField().createFilterOrErrorMessage('');
-            expect(filter.error).toStrictEqual('empty line');
-        });
-
-        it('Invalid AND', () => {
-            const filter = new BooleanField().createFilterOrErrorMessage('AND (description includes d1)');
-            expect(filter.error).toStrictEqual(
+        it.each([
+            [
+                // force line break
+                '',
+                'empty line',
+            ],
+
+            // Incorrect numbers of filters/sub-expressions
+            [
+                'AND (description includes d1)',
                 'malformed boolean query -- Invalid token (check the documentation for guidelines)',
-            );
-        });
-
-        it('Invalid OR', () => {
-            const filter = new BooleanField().createFilterOrErrorMessage('OR (description includes d1)');
-            expect(filter.error).toStrictEqual(
+            ],
+            [
+                'OR (description includes d1)',
                 'malformed boolean query -- Invalid token (check the documentation for guidelines)',
-            );
-        });
-
-        it('Invalid sub-expression', () => {
-            const filter = new BooleanField().createFilterOrErrorMessage('NOT (description blahblah d1)');
-            expect(filter.error).toStrictEqual("couldn't parse sub-expression 'description blahblah d1'");
-        });
-
-        it('Invalid sub-expression - gives error', () => {
-            const filter = new BooleanField().createFilterOrErrorMessage('NOT (happens before blahblahblah)');
-            expect(filter.error).toStrictEqual(
+            ],
+            [
+                // force line break
+                'NOT (description blahblah d1)',
+                "couldn't parse sub-expression 'description blahblah d1'",
+            ],
+            [
+                'NOT (happens before blahblahblah)',
                 "couldn't parse sub-expression 'happens before blahblahblah': do not understand happens date",
-            );
-        });
+            ],
+
+            // Missing spaces before operator
+            [
+                '(path includes a)AND (path includes b)',
+                'malformed boolean query -- Unexpected character: A. A closing parenthesis should be followed by another closing parenthesis or whitespace (check the documentation for guidelines)',
+            ],
+            [
+                '(path includes a)AND NOT(path includes b)',
+                'malformed boolean query -- Unexpected character: A. A closing parenthesis should be followed by another closing parenthesis or whitespace (check the documentation for guidelines)',
+            ],
+            [
+                '(path includes a)OR (path includes b)',
+                'malformed boolean query -- Unexpected character: O. A closing parenthesis should be followed by another closing parenthesis or whitespace (check the documentation for guidelines)',
+            ],
+            [
+                '(path includes a)OR NOT (path includes b)',
+                'malformed boolean query -- Unexpected character: O. A closing parenthesis should be followed by another closing parenthesis or whitespace (check the documentation for guidelines)',
+            ],
+            [
+                '(path includes a)XOR (path includes b)',
+                'malformed boolean query -- Unexpected character: X. A closing parenthesis should be followed by another closing parenthesis or whitespace (check the documentation for guidelines)',
+            ],
+
+            // Missing spaces after operator
+            [
+                '(path includes a) AND(path includes b)',
+                'malformed boolean query -- Unexpected character: (. Operators should be separated using whitespace (check the documentation for guidelines)',
+            ],
+            [
+                '(path includes a) AND NOT(path includes b)',
+                'malformed boolean query -- Unexpected character: (. Operators should be separated using whitespace (check the documentation for guidelines)',
+            ],
+            [
+                '(path includes a) OR(path includes b)',
+                'malformed boolean query -- Unexpected character: (. Operators should be separated using whitespace (check the documentation for guidelines)',
+            ],
+            [
+                '(path includes a) OR NOT(path includes b)',
+                'malformed boolean query -- Unexpected character: (. Operators should be separated using whitespace (check the documentation for guidelines)',
+            ],
+            [
+                '(path includes a) XOR(path includes b)',
+                'malformed boolean query -- Unexpected character: (. Operators should be separated using whitespace (check the documentation for guidelines)',
+            ],
+            [
+                'NOT(path includes b)',
+                'malformed boolean query -- Unexpected character: (. Operators should be separated using whitespace (check the documentation for guidelines)',
+            ],
+        ])(
+            'should report expected error message: on "%s" - expected "%s"',
+            (instruction: string, expectedError: string) => {
+                const filter = new BooleanField().createFilterOrErrorMessage(instruction);
+                expect(filter.error).toStrictEqual(expectedError);
+            },
+        );
 
         // Have not managed to create instructions that trigger these errors:
         //      result.error = 'empty operator in boolean query';
@@ -159,156 +210,12 @@ describe('boolean query', () => {
     });
 });
 
-describe('explain boolean queries', () => {
-    it('should explain Boolean AND', () => {
-        const instruction = '(description includes d1) AND (priority medium)';
-        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
-        const expected = `AND (All of):
-  description includes d1
-  priority is medium`;
-        expect(filterOrMessage).toHaveExplanation(expected);
-    });
-
-    it('should explain Boolean OR', () => {
-        const instruction = '(description includes d1) OR (priority medium)';
-        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
-        const expected = `OR (At least one of):
-  description includes d1
-  priority is medium`;
-        expect(filterOrMessage).toHaveExplanation(expected);
-    });
-
-    it('should explain Boolean NOT', () => {
-        const instruction = 'NOT (description includes d1)';
-        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
-        expect(filterOrMessage).toHaveExplanation('NOT:\n  description includes d1');
-    });
-
-    it('should explain Boolean XOR', () => {
-        const instruction = '(description includes d1) XOR (priority medium)';
-        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
-        const expected = `XOR (Exactly one of):
-  description includes d1
-  priority is medium`;
-        expect(filterOrMessage).toHaveExplanation(expected);
-    });
-
-    it('should explain 2 Boolean ORs', () => {
-        const instruction = '(description includes d1) OR (description includes d2) OR (priority medium)';
-        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
-        const expected = `OR (At least one of):
-  description includes d1
-  description includes d2
-  priority is medium`;
-        expect(filterOrMessage).toHaveExplanation(expected);
-    });
-
-    it('should explain 3 Boolean ANDs', () => {
-        const instruction = '(description includes 1) AND (description includes 2) AND (description includes 3)';
-        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
-        expect(filterOrMessage.filter?.explanation.asString()).toMatchInlineSnapshot(`
-            "AND (All of):
-              description includes 1
-              description includes 2
-              description includes 3"
-        `);
-    });
-
-    it('should explain 9 Boolean ANDs', () => {
-        const instruction =
-            '(description includes 1) AND (description includes 2) AND (description includes 3) AND (description includes 4) AND (description includes 5) AND (description includes 6) AND (description includes 7) AND (description includes 8) AND (description includes 9)';
-        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
-        expect(filterOrMessage.filter?.explanation.asString()).toMatchInlineSnapshot(`
-            "AND (All of):
-              description includes 1
-              description includes 2
-              description includes 3
-              description includes 4
-              description includes 5
-              description includes 6
-              description includes 7
-              description includes 8
-              description includes 9"
-        `);
-    });
-
-    it('( a && b ) && c', () => {
-        const instruction = '( (description includes a) AND (description includes b) ) AND (description includes c)';
-        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
-        expect(filterOrMessage.filter?.explanation.asString()).toMatchInlineSnapshot(`
-            "AND (All of):
-              description includes a
-              description includes b
-              description includes c"
-        `);
-    });
-
-    it('a && ( b && c )', () => {
-        const instruction = '( description includes a ) AND ( (description includes b) AND (description includes c) )';
-        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
-        expect(filterOrMessage.filter?.explanation.asString()).toMatchInlineSnapshot(`
-            "AND (All of):
-              description includes a
-              AND (All of):
-                description includes b
-                description includes c"
-        `);
-    });
-
-    it('( a || b ) || c', () => {
-        const instruction = '( (description includes a) OR (description includes b) ) OR (description includes c)';
-        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
-        expect(filterOrMessage.filter?.explanation.asString()).toMatchInlineSnapshot(`
-            "OR (At least one of):
-              description includes a
-              description includes b
-              description includes c"
-        `);
-    });
-
-    it('a || ( b || c )', () => {
-        const instruction = '( description includes a ) OR ( (description includes b) OR (description includes c) )';
-        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
-        expect(filterOrMessage.filter?.explanation.asString()).toMatchInlineSnapshot(`
-            "OR (At least one of):
-              description includes a
-              OR (At least one of):
-                description includes b
-                description includes c"
-        `);
-    });
-
-    it('( a && b && c ) || ( d && e && f )', () => {
-        const instruction =
-            '( (description includes a) AND (description includes b) AND (description includes c) ) OR ( (description includes d) AND (description includes e) AND (description includes f) )';
-        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
-        expect(filterOrMessage.filter?.explanation.asString()).toMatchInlineSnapshot(`
-            "OR (At least one of):
-              AND (All of):
-                description includes a
-                description includes b
-                description includes c
-              AND (All of):
-                description includes d
-                description includes e
-                description includes f"
-        `);
+describe('boolean query - exhaustive tests', () => {
+    it('preprocess', () => {
+        verifyBooleanExpressionPreprocessing(BooleanField.preprocessExpression);
     });
 
-    it('( a || b || c ) && ( d || e || f )', () => {
-        const instruction =
-            '( (description includes a) OR (description includes b) OR (description includes c) ) AND ( (description includes d) OR (description includes e) OR (description includes f) )';
-        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
-        expect(filterOrMessage.filter?.explanation.asString()).toMatchInlineSnapshot(`
-            "AND (All of):
-              OR (At least one of):
-                description includes a
-                description includes b
-                description includes c
-              OR (At least one of):
-                description includes d
-                description includes e
-                description includes f"
-        `);
+    it('explain', () => {
+        verifyBooleanExpressionExplanation();
     });
 });
diff --git a/tests/Query/Filter/BooleanFieldVerify.ts b/tests/Query/Filter/BooleanFieldVerify.ts
new file mode 100644
index 00000000..b6c1f5ac
--- /dev/null
+++ b/tests/Query/Filter/BooleanFieldVerify.ts
@@ -0,0 +1,94 @@
+import { verifyAll } from 'approvals/lib/Providers/Jest/JestApprovals';
+import { errorMessageForException } from '../../../src/lib/ExceptionTools';
+import { BooleanField } from '../../../src/Query/Filter/BooleanField';
+
+export const inputs = `
+"description includes d1" AND "description includes d2"
+( (description includes a) AND (description includes b) ) AND (description includes c)
+( (description includes a) AND (description includes b) AND (description includes c) ) OR ( (description includes d) AND (description includes e) AND (description includes f) )
+( (description includes a) OR (description includes b) ) OR (description includes c)
+( (description includes a) OR (description includes b) OR (description includes c) ) AND ( (description includes d) OR (description includes e) OR (description includes f) )
+( (filter by function task.description.includes('a')) OR (filter by function task.description.includes('b')) OR (filter by function task.description.includes('c')) ) AND ( (filter by function task.description.includes('d')) OR (filter by function task.description.includes('e')) OR (filter by function task.description.includes('f')) )
+( description includes a ) AND ( (description includes b) AND (description includes c) )
+( description includes a ) OR ( (description includes b) OR (description includes c) )
+(((((description includes #context/location1)))))
+(description includes #context/location1)
+(description includes #context/location1) OR (description includes #context/location2 ) OR (  description includes #context/location3 ) OR   (  description includes #context/location4 )
+(description includes 1) AND (description includes 2) AND (description includes 3)
+(description includes 1) AND (description includes 2) AND (description includes 3) AND (description includes 4) AND (description includes 5) AND (description includes 6) AND (description includes 7) AND (description includes 8) AND (description includes 9)
+(description includes 1)   AND   (description includes 2)   AND   (description includes 3)   AND   (description includes 4)   AND   (description includes 5)   AND   (description includes 6)   AND   (description includes 7)   AND   (description includes 8)   AND   (description includes 9)
+(description includes d1) AND (priority medium)
+(description includes d1) AND NOT (description includes d2)
+(description includes d1) AND NOT (priority medium)
+(description includes d1) OR (description includes d2)
+(description includes d1) OR (description includes d2) OR (priority medium)
+(description includes d1) OR (priority medium)
+(description includes d1) OR NOT (description includes d2)
+(description includes d1) OR NOT (priority medium)
+(description includes d1) XOR (description includes d2)
+(description includes d1) XOR (priority medium)
+
+AND (description includes d1)
+NOT (description blahblah d1)
+NOT (description includes d1)
+NOT (happens before blahblahblah)
+OR (description includes d1)
+(description includes SHOULD NOT BE RECOGNISED AS A BOOLEAN)AND(description includes BECAUSE THERE ARE NO SPACES AROUND THE 'AND' OPERATOR)
+
+(path includes (some example) OR (path includes )some example()
+(path includes (some example)) OR (path includes )some example()
+(path includes )some example() OR (path includes (some example))
+(path includes ()some example()) OR (path includes ((some example)))
+
+( description regex matches /(buy|order|voucher|lakeland|purchase|\\spresent)/i ) OR ( path includes Home/Shopping )
+( filter by function ! 'NON_TASK,CANCELLED'.includes(task.status.type) ) OR ( filter by function const date = task.due.moment; return date ? !date.isValid() : false; ) OR ( filter by function task.due.moment?.isSameOrBefore(moment(), 'day') || false ) OR ( filter by function task.urgency.toFixed(2) === 1.95.toFixed(2) ) OR ( filter by function (!task.isRecurring) && task.originalMarkdown.includes('🔁') ) OR ( filter by function task.file.path.toLocaleLowerCase() === 'TASKS RELEASES/4.1.0 RELEASE.MD'.toLocaleLowerCase() ) OR ( filter by function const taskDate = task.due.moment; const now = moment(); return taskDate?.isSame(now, 'day') || ( !taskDate && task.heading?.includes(now.format('YYYY-MM-DD')) ) || false ) OR ( filter by function const wanted = '#context/home'; return task.heading?.includes(wanted) || task.tags.find( (tag) => tag === wanted ) && true || false; )
+`;
+
+export function verifyBooleanExpressionPreprocessing(fn: (text: string) => any) {
+    verifyAll('Results of preprocessing boolean expressions', inputs.split('\n'), (input) => {
+        if (input.trim() === '') {
+            return '';
+        }
+
+        let result: string = '';
+        try {
+            result = JSON.stringify(fn(input), null, 4);
+        } catch (e) {
+            result = errorMessageForException('Parsing expression', e);
+        }
+        return `
+Input:
+'${input}'
+=>
+Result:
+${result}
+
+--------------------------------------------------------
+`;
+    });
+}
+
+export function verifyBooleanExpressionExplanation() {
+    verifyAll('Results of parsing and explaining boolean expressions', inputs.split('\n'), (input) => {
+        if (input.trim() === '') {
+            return '';
+        }
+
+        let result: string = '';
+        const filterOrErrorMessage = new BooleanField().createFilterOrErrorMessage(input);
+        if (filterOrErrorMessage.isValid()) {
+            result = filterOrErrorMessage.filter!.explainFilterIndented('  ');
+        } else {
+            result = filterOrErrorMessage.error!;
+        }
+        return `
+Input:
+'${input}'
+=>
+Result:
+${result}
+
+--------------------------------------------------------
+`;
+    });
+}
diff --git a/tests/Query/Filter/Filter.test.ts b/tests/Query/Filter/Filter.test.ts
index dea2db4c..b4a112bf 100644
--- a/tests/Query/Filter/Filter.test.ts
+++ b/tests/Query/Filter/Filter.test.ts
@@ -15,6 +15,13 @@ describe('Filter', () => {
         expect(filter.instruction).toEqual(line);
         expect(filter.explanation.asString()).toEqual(line);
         expect(filter.filterFunction).not.toBeUndefined();
+
+        expect(filter.statement.rawInstruction).toEqual(line);
+        expect(filter.statement.anyContinuationLinesRemoved).toEqual(line);
+        expect(filter.statement.anyPlaceholdersExpanded).toEqual(line);
+
+        // Note: there is also a test in Query.test.ts that verifies correct populating
+        //       of filter.statement via the Query constructor.
     });
 
     it('should create a Filter object with different explanation', () => {
diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts
index 2279d0ec..65e4876c 100644
--- a/tests/Query/Query.test.ts
+++ b/tests/Query/Query.test.ts
@@ -1459,6 +1459,23 @@ describe('Query', () => {
     });
 
     describe('line continuations', () => {
+        it('should save the source correctly in a Statement object', () => {
+            const source = String.raw`(path includes A) OR \
+                (path includes {{query.file.path}})`;
+            const query = new Query(source, 'Test.md');
+
+            expect(query.error).toBeUndefined();
+            const filter = query.filters[0];
+            expect(filter.statement.rawInstruction).toEqual(source);
+            expect(filter.statement.anyContinuationLinesRemoved).toEqual(
+                '(path includes A) OR (path includes {{query.file.path}})',
+            );
+            expect(filter.statement.anyPlaceholdersExpanded).toEqual('(path includes A) OR (path includes Test.md)');
+
+            // Self-consistency check:
+            expect(filter.statement.anyPlaceholdersExpanded).toEqual(filter.instruction);
+        });
+
         it('should work in group by functions', () => {
             const source = String.raw`group by function \
                 const date = task.due.moment; \
@@ -1471,6 +1488,7 @@ describe('Query', () => {
             const query = new Query(source);
             expect(query.error).toBeUndefined();
         });
+
         it('should be explained correctly in boolean queries', () => {
             const source = String.raw`explain
 (description includes line 1) OR        \
@@ -1479,7 +1497,11 @@ with \ backslash)`;
             const query = new Query(source);
             const queryUpperCase = new Query(source);
             expect(query.explainQuery()).toMatchInlineSnapshot(`
-                "(description includes line 1) OR (description includes line 1 continued with \\ backslash) =>
+                "(description includes line 1) OR        \\
+                  (description includes line 1 continued\\
+                with \\ backslash)
+                 =>
+                (description includes line 1) OR (description includes line 1 continued with \\ backslash) =>
                   OR (At least one of):
                     description includes line 1
                     description includes line 1 continued with \\ backslash
@@ -1490,7 +1512,11 @@ with \ backslash)`;
                 "
             `);
             expect(queryUpperCase.explainQuery()).toMatchInlineSnapshot(`
-                "(description includes line 1) OR (description includes line 1 continued with \\ backslash) =>
+                "(description includes line 1) OR        \\
+                  (description includes line 1 continued\\
+                with \\ backslash)
+                 =>
+                (description includes line 1) OR (description includes line 1 continued with \\ backslash) =>
                   OR (At least one of):
                     description includes line 1
                     description includes line 1 continued with \\ backslash
diff --git a/tests/Query/Scanner.test.continue_lines_visualise_continue_lines.approved.txt b/tests/Query/Scanner.test.continue_lines_visualise_continue_lines.approved.txt
index 018406e6..d9f447ea 100644
--- a/tests/Query/Scanner.test.continue_lines_visualise_continue_lines.approved.txt
+++ b/tests/Query/Scanner.test.continue_lines_visualise_continue_lines.approved.txt
@@ -34,37 +34,118 @@ description includes 15 hello world\
 description includes 16 hello world \ 
 description includes 17 hello world \\ 
 
+  description includes 17 hello world  
 
 -------------------------------------
 
-result after calling continue_lines():
+result after calling continueLines():
 -------------------------------------
 
-description includes 1 hello world    
-
-description includes 2 hello world   
-
-description includes 3 hello world
-
-description includes 4 hello world
-
-description includes 5 hello    \
-description includes 6 world
-
-description includes 7 hello world 
-description includes 8 hello world\
-
-description includes 9 hello world description includes 10 hello world\
-
-description includes 11 hello world\
-description includes 12 hello world
-
-description includes 13 hello world\
-description includes 14 hello world\
-
-description includes 15 hello world 
-description includes 16 hello world \ 
-description includes 17 hello world \\ 
-
-
+"description includes 1 hello world    "
+=>
+"description includes 1 hello world"
+=>
+"description includes 1 hello world"
+
+"description includes 2 hello   \
+   world   "
+=>
+"description includes 2 hello world"
+=>
+"description includes 2 hello world"
+
+"description includes 3 hello \
+world"
+=>
+"description includes 3 hello world"
+=>
+"description includes 3 hello world"
+
+"description includes 4 hello    \
+world"
+=>
+"description includes 4 hello world"
+=>
+"description includes 4 hello world"
+
+"description includes 5 hello    \\"
+=>
+"description includes 5 hello    \"
+=>
+"description includes 5 hello    \"
+
+"description includes 6 world"
+=>
+"description includes 6 world"
+=>
+"description includes 6 world"
+
+"description includes 7 hello world\
+"
+=>
+"description includes 7 hello world"
+=>
+"description includes 7 hello world"
+
+"description includes 8 hello world\\"
+=>
+"description includes 8 hello world\"
+=>
+"description includes 8 hello world\"
+
+"description includes 9 hello world\
+description includes 10 hello world\\"
+=>
+"description includes 9 hello world description includes 10 hello world\"
+=>
+"description includes 9 hello world description includes 10 hello world\"
+
+"description includes 11 hello world\\"
+=>
+"description includes 11 hello world\"
+=>
+"description includes 11 hello world\"
+
+"description includes 12 hello world"
+=>
+"description includes 12 hello world"
+=>
+"description includes 12 hello world"
+
+"description includes 13 hello world\\"
+=>
+"description includes 13 hello world\"
+=>
+"description includes 13 hello world\"
+
+"description includes 14 hello world\\"
+=>
+"description includes 14 hello world\"
+=>
+"description includes 14 hello world\"
+
+"description includes 15 hello world\
+"
+=>
+"description includes 15 hello world"
+=>
+"description includes 15 hello world"
+
+"description includes 16 hello world \ "
+=>
+"description includes 16 hello world \"
+=>
+"description includes 16 hello world \"
+
+"description includes 17 hello world \\ "
+=>
+"description includes 17 hello world \\"
+=>
+"description includes 17 hello world \\"
+
+"  description includes 17 hello world  "
+=>
+"description includes 17 hello world"
+=>
+"description includes 17 hello world"
 -------------------------------------
diff --git a/tests/Query/Scanner.test.scan_visualise_scanning.approved.txt b/tests/Query/Scanner.test.scan_visualise_scanning.approved.txt
index 59d3fe87..d2dbb4b9 100644
--- a/tests/Query/Scanner.test.scan_visualise_scanning.approved.txt
+++ b/tests/Query/Scanner.test.scan_visualise_scanning.approved.txt
@@ -34,6 +34,7 @@ description includes 15 hello world\
 description includes 16 hello world \ 
 description includes 17 hello world \\ 
 
+  description includes 17 hello world  
 
 -------------------------------------
 
@@ -55,4 +56,5 @@ description includes 14 hello world\
 description includes 15 hello world
 description includes 16 hello world \
 description includes 17 hello world \\
+description includes 17 hello world
 -------------------------------------
diff --git a/tests/Query/Scanner.test.ts b/tests/Query/Scanner.test.ts
index 09e8b7f7..1b80e27e 100644
--- a/tests/Query/Scanner.test.ts
+++ b/tests/Query/Scanner.test.ts
@@ -1,5 +1,5 @@
 import { verify } from 'approvals/lib/Providers/Jest/JestApprovals';
-import { continue_lines, scan } from '../../src/Query/Scanner';
+import { continueLines, continueLinesFlattened, scan } from '../../src/Query/Scanner';
 
 // There is no way to have a literal \ at the end of a raw string.
 // In such cases, we use substitution instead.
@@ -40,6 +40,7 @@ const querySource = [
     'description includes 16 hello world \\ ',
     'description includes 17 hello world \\\\ ',
     '',
+    '  description includes 17 hello world  ',
     '',
 ].join('\n');
 
@@ -50,7 +51,7 @@ describe('continue_lines', () => {
             'not done',
             'due this week',
         ].join('\n');
-        expect(continue_lines(text)).toEqual(text);
+        expect(continueLinesFlattened(text)).toEqual(text);
     });
 
     it('removes backslashed newlines', () => {
@@ -59,7 +60,7 @@ describe('continue_lines', () => {
             String.raw`line1 ${bs}`,
             'continued',
         ].join('\n');
-        expect(continue_lines(text)).toEqual('line1 continued');
+        expect(continueLinesFlattened(text)).toEqual('line1 continued');
     });
 
     it('only consumes one backslash', () => {
@@ -69,11 +70,10 @@ describe('continue_lines', () => {
             '',
             'line2',
         ].join('\n');
-        expect(continue_lines(text)).toEqual(
+        expect(continueLinesFlattened(text)).toEqual(
             [
                 // force linebreak
                 String.raw`line1 ${bs}`,
-                '',
                 'line2',
             ].join('\n'),
         );
@@ -84,14 +84,12 @@ describe('continue_lines', () => {
             // force linebreak
             String.raw`line\1 ${bs}`,
             `continued ${bs}${bs}${bs}`,
-            '',
             'line2',
         ].join('\n');
-        expect(continue_lines(text)).toEqual(
+        expect(continueLinesFlattened(text)).toEqual(
             [
                 // force linebreak
                 String.raw`line\1 continued \\`,
-                '',
                 'line2',
             ].join('\n'),
         );
@@ -101,14 +99,12 @@ describe('continue_lines', () => {
         const text = [
             // force linebreak
             String.raw`line1${bs}${bs}`,
-            '',
             'line2',
         ].join('\n');
-        expect(continue_lines(text)).toEqual(
+        expect(continueLinesFlattened(text)).toEqual(
             [
                 // force linebreak
                 String.raw`line1${bs}`,
-                '',
                 'line2',
             ].join('\n'),
         );
@@ -120,7 +116,7 @@ describe('continue_lines', () => {
             String.raw`line1    ${bs}`,
             'continued',
         ].join('\n');
-        expect(continue_lines(text)).toEqual(String.raw`line1 continued`);
+        expect(continueLinesFlattened(text)).toEqual(String.raw`line1 continued`);
     });
 
     it('compresses surrounding tabs', () => {
@@ -131,10 +127,10 @@ describe('continue_lines', () => {
             '',
             'line2',
         ].join('\n');
-        expect(continue_lines(text)).toEqual(
+        expect(continueLinesFlattened(text)).toEqual(
             [
                 // force linebreak
-                'line1 continued ',
+                'line1 continued',
                 'line2',
             ].join('\n'),
         );
@@ -151,7 +147,7 @@ describe('continue_lines', () => {
             '   five \\',
             '   six',
         ].join('\n');
-        expect(continue_lines(text)).toEqual('description includes one two three four five six');
+        expect(continueLinesFlattened(text)).toEqual('description includes one two three four five six');
     });
 
     it('visualise continue_lines', () => {
@@ -161,9 +157,18 @@ input:
 ${querySource}
 -------------------------------------
 
-result after calling continue_lines():
+result after calling continueLines():
 -------------------------------------
-${continue_lines(querySource)}
+${continueLines(querySource)
+    .map(
+        (statement) => `
+"${statement.rawInstruction}"
+=>
+"${statement.anyContinuationLinesRemoved}"
+=>
+"${statement.anyPlaceholdersExpanded}"`,
+    )
+    .join('\n')}
 -------------------------------------
 `;
         verify(output);
@@ -185,9 +190,6 @@ describe('scan', () => {
             // force line break
             ' not done   ',
             '        due this week',
-            '',
-            '        ',
-            '        ',
         ].join('\n');
         expect(scan(text)).toEqual(['not done', 'due this week']);
     });
diff --git a/tests/Query/Statement.test.ts b/tests/Query/Statement.test.ts
new file mode 100644
index 00000000..aa804c19
--- /dev/null
+++ b/tests/Query/Statement.test.ts
@@ -0,0 +1,37 @@
+import { Statement } from '../../src/Query/Statement';
+
+describe('Statement', () => {
+    it('should store the instruction', () => {
+        const raw = `hello \\
+world`;
+        const final = 'hello world';
+        const instruction = new Statement(raw, final);
+        expect(instruction.rawInstruction).toEqual(raw);
+        expect(instruction.anyContinuationLinesRemoved).toEqual('hello world');
+        expect(instruction.anyPlaceholdersExpanded).toEqual('hello world');
+    });
+
+    describe('explaining statements', () => {
+        it('should explain simplest statement', () => {
+            const instruction = 'hello world';
+            const statement = new Statement(instruction, instruction);
+            expect(statement.explainStatement('')).toEqual(instruction);
+            expect(statement.explainStatement('  ')).toEqual('  hello world');
+        });
+
+        it('should explain statement with extra white space', () => {
+            const instruction = '   hello world   ';
+            const statement = new Statement(instruction, instruction);
+            expect(statement.explainStatement('')).toEqual('hello world');
+            expect(statement.explainStatement('  ')).toEqual('  hello world');
+        });
+
+        it('should show unexpanded and expanded placeholders, if they differ', () => {
+            const instruction = '{{fake placeholder}}';
+            const statement = new Statement(instruction, instruction);
+            statement.recordExpandedPlaceholders('expanded placeholder');
+            expect(statement.explainStatement('  ')).toEqual(`  {{fake placeholder}} =>
+  expanded placeholder`);
+        });
+    });
+});
diff --git a/tests/Renderer/TaskLineRenderer.test.ts b/tests/Renderer/TaskLineRenderer.test.ts
index 853c8727..394f3352 100644
--- a/tests/Renderer/TaskLineRenderer.test.ts
+++ b/tests/Renderer/TaskLineRenderer.test.ts
@@ -2,7 +2,6 @@
  * @jest-environment jsdom
  */
 import moment from 'moment';
-import * as prettier from 'prettier';
 
 import { DebugSettings } from '../../src/Config/DebugSettings';
 import { GlobalFilter } from '../../src/Config/GlobalFilter';
@@ -15,6 +14,7 @@ import { TaskLineRenderer } from '../../src/Renderer/TaskLineRenderer';
 import type { Task } from '../../src/Task/Task';
 import { TaskRegularExpressions } from '../../src/Task/TaskRegularExpressions';
 import { verifyWithFileExtension } from '../TestingTools/ApprovalTestHelpers';
+import { prettifyHTML } from '../TestingTools/HTMLHelpers';
 import { TaskBuilder } from '../TestingTools/TaskBuilder';
 import { fromLine } from '../TestingTools/TestHelpers';
 
@@ -544,13 +544,7 @@ describe('Visualise HTML', () => {
         const taskAsMarkdown = `<!--
 ${task.toFileLineString()}
 -->\n\n`;
-        const taskAsHTML = listItem.outerHTML;
-        const prettyHTML = prettier.format(taskAsHTML, {
-            parser: 'html',
-            bracketSameLine: true,
-            htmlWhitespaceSensitivity: 'ignore',
-            printWidth: 120,
-        });
+        const prettyHTML = prettifyHTML(listItem.outerHTML);
 
         verifyWithFileExtension(taskAsMarkdown + prettyHTML, 'html');
     }
diff --git a/tests/Scripting/Expression.test.ts b/tests/Scripting/Expression.test.ts
index 28b0b648..c1d1841c 100644
--- a/tests/Scripting/Expression.test.ts
+++ b/tests/Scripting/Expression.test.ts
@@ -6,7 +6,7 @@ import moment from 'moment';
 import { evaluateExpression, evaluateExpressionOrCatch, parseExpression } from '../../src/Scripting/Expression';
 import { TaskBuilder } from '../TestingTools/TaskBuilder';
 import { verifyMarkdownForDocs } from '../TestingTools/VerifyMarkdown';
-import { continue_lines } from '../../src/Query/Scanner';
+import { continueLinesFlattened } from '../../src/Query/Scanner';
 import { constructArguments, parseAndEvaluateExpression } from '../../src/Scripting/TaskExpression';
 import { makeQueryContext } from '../../src/Scripting/QueryContext';
 import { formatToRepresentType } from './ScriptingTestHelpers';
@@ -112,7 +112,7 @@ describe('Expression', () => {
         markdown +=
             expressions
                 .map((expression) => {
-                    const result = parseAndEvaluateExpression(task, continue_lines(expression), queryContext);
+                    const result = parseAndEvaluateExpression(task, continueLinesFlattened(expression), queryContext);
                     return `${expression}${resultSeparator}=> ${formatToRepresentType(result)}`;
                 })
                 .join(separator) + '\n';
diff --git a/tests/TestingTools/HTMLHelpers.ts b/tests/TestingTools/HTMLHelpers.ts
new file mode 100644
index 00000000..2351f425
--- /dev/null
+++ b/tests/TestingTools/HTMLHelpers.ts
@@ -0,0 +1,10 @@
+import * as prettier from 'prettier';
+
+export function prettifyHTML(modalHTML: string) {
+    return prettier.format(modalHTML, {
+        parser: 'html',
+        bracketSameLine: true,
+        htmlWhitespaceSensitivity: 'ignore',
+        printWidth: 120,
+    });
+}
diff --git a/tests/ui/EditTask.test.Edit_Modal_HTML_snapshot_tests_should_match_snapshot.approved.html b/tests/ui/EditTask.test.Edit_Modal_HTML_snapshot_tests_should_match_snapshot.approved.html
new file mode 100644
index 00000000..04b9ac08
--- /dev/null
+++ b/tests/ui/EditTask.test.Edit_Modal_HTML_snapshot_tests_should_match_snapshot.approved.html
@@ -0,0 +1,245 @@
+<div>
+  <div class="tasks-modal">
+    <form class="with-accesskeys">
+      <div class="tasks-modal-section">
+        <label for="description">
+          Descrip
+          <span class="accesskey">t</span>
+          ion
+        </label>
+        <textarea
+          id="description"
+          class="tasks-modal-description"
+          placeholder="Take out the trash"
+          accesskey="t"></textarea>
+      </div>
+      <div class="tasks-modal-section tasks-modal-priorities">
+        <label for="priority-none">Priority</label>
+        <span>
+          <input type="radio" id="priority-lowest" value="lowest" accesskey="o" />
+          <label for="priority-lowest">
+            <span>L</span>
+            <span class="accesskey">o</span>
+            <span>west</span>
+            <span>⏬</span>
+          </label>
+        </span>
+        <span>
+          <input type="radio" id="priority-low" value="low" accesskey="l" />
+          <label for="priority-low">
+            <span></span>
+            <span class="accesskey">L</span>
+            <span>ow</span>
+            <span>🔽</span>
+          </label>
+        </span>
+        <span>
+          <input type="radio" id="priority-none" value="none" accesskey="n" />
+          <label for="priority-none">
+            <span></span>
+            <span class="accesskey">N</span>
+            <span>ormal</span>
+          </label>
+        </span>
+        <span>
+          <input type="radio" id="priority-medium" value="medium" accesskey="m" />
+          <label for="priority-medium">
+            <span></span>
+            <span class="accesskey">M</span>
+            <span>edium</span>
+            <span>🔼</span>
+          </label>
+        </span>
+        <span>
+          <input type="radio" id="priority-high" value="high" accesskey="h" />
+          <label for="priority-high">
+            <span></span>
+            <span class="accesskey">H</span>
+            <span>igh</span>
+            <span>⏫</span>
+          </label>
+        </span>
+        <span>
+          <input type="radio" id="priority-highest" value="highest" accesskey="i" />
+          <label for="priority-highest">
+            <span>H</span>
+            <span class="accesskey">i</span>
+            <span>ghest</span>
+            <span>🔺</span>
+          </label>
+        </span>
+      </div>
+      <div class="tasks-modal-section tasks-modal-dates">
+        <label for="recurrence" class="accesskey-first">Recurs</label>
+        <input id="recurrence" type="text" class="input" placeholder="Try 'every 2 weeks on Thursday'." accesskey="r" />
+        <code class="results">
+          🔁
+          <i>not recurring</i>
+        </code>
+      </div>
+      <hr />
+      <div class="tasks-modal-section tasks-modal-dates">
+        <label for="due" class="accesskey-first">Due</label>
+        <input
+          id="due"
+          type="text"
+          class="input"
+          placeholder="Try 'Monday' or 'tomorrow', or [td|tm|yd|tw|nw|we] then space."
+          accesskey="d" />
+        <code class="results">
+          📅
+          <i>no due date</i>
+        </code>
+        <label for="scheduled" class="accesskey-first">Scheduled</label>
+        <input
+          id="scheduled"
+          type="text"
+          class="input"
+          placeholder="Try 'Monday' or 'tomorrow', or [td|tm|yd|tw|nw|we] then space."
+          accesskey="s" />
+        <code class="results">
+          ⏳
+          <i>no scheduled date</i>
+        </code>
+        <label for="start">
+          St
+          <span class="accesskey">a</span>
+          rt
+        </label>
+        <input
+          id="start"
+          type="text"
+          class="input"
+          placeholder="Try 'Monday' or 'tomorrow', or [td|tm|yd|tw|nw|we] then space."
+          accesskey="a" />
+        <code class="results">
+          🛫
+          <i>no start date</i>
+        </code>
+        <div>
+          <label for="forwardOnly">
+            Only
+            <span class="accesskey-first">future</span>
+            dates:
+          </label>
+          <input
+            id="forwardOnly"
+            type="checkbox"
+            class="input task-list-item-checkbox tasks-modal-checkbox"
+            accesskey="f" />
+        </div>
+      </div>
+      <hr />
+      <div class="tasks-modal-section tasks-modal-dates">
+        <label for="blockedBy" class="accesskey-first">Before this</label>
+        <span class="input">
+          <input
+            accesskey="b"
+            id="blockedBy"
+            class="input"
+            type="text"
+            placeholder="Search for tasks that the task being edited depends on..." />
+          <iframe
+            style="
+              display: block;
+              position: absolute;
+              top: 0;
+              left: 0;
+              width: 100%;
+              height: 100%;
+              overflow: hidden;
+              border: 0;
+              opacity: 0;
+              pointer-events: none;
+              z-index: -1;
+            "
+            aria-hidden="true"
+            tabindex="-1"
+            src="about:blank"></iframe>
+        </span>
+        <div class="task-dependencies-container results"></div>
+        <label for="blocking">
+          Aft
+          <span class="accesskey">e</span>
+          r this
+        </label>
+        <span class="input">
+          <input
+            accesskey="e"
+            id="blocking"
+            class="input"
+            type="text"
+            placeholder="Search for tasks that depend on this task being done..." />
+          <iframe
+            style="
+              display: block;
+              position: absolute;
+              top: 0;
+              left: 0;
+              width: 100%;
+              height: 100%;
+              overflow: hidden;
+              border: 0;
+              opacity: 0;
+              pointer-events: none;
+              z-index: -1;
+            "
+            aria-hidden="true"
+            tabindex="-1"
+            src="about:blank"></iframe>
+        </span>
+        <div class="task-dependencies-container results"></div>
+      </div>
+      <hr />
+      <div class="tasks-modal-section">
+        <label for="status">
+          Stat
+          <span class="accesskey">u</span>
+          s
+        </label>
+        <select id="status-type" class="dropdown" accesskey="u">
+          <option value=" ">Todo [ ]</option>
+          <option value="/">In Progress [/]</option>
+          <option value="x">Done [x]</option>
+          <option value="-">Cancelled [-]</option>
+        </select>
+      </div>
+      <div class="tasks-modal-section tasks-modal-dates">
+        <label for="created">Created</label>
+        <input
+          id="created"
+          type="text"
+          class="input"
+          placeholder="Try 'Monday' or 'tomorrow', or [td|tm|yd|tw|nw|we] then space." />
+        <code class="results">
+          ➕
+          <i>no created date</i>
+        </code>
+        <label for="done">Done</label>
+        <input
+          id="done"
+          type="text"
+          class="input"
+          placeholder="Try 'Monday' or 'tomorrow', or [td|tm|yd|tw|nw|we] then space." />
+        <code class="results">
+          ✅
+          <i>no done date</i>
+        </code>
+        <label for="cancelled">Cancelled</label>
+        <input
+          id="cancelled"
+          type="text"
+          class="input"
+          placeholder="Try 'Monday' or 'tomorrow', or [td|tm|yd|tw|nw|we] then space." />
+        <code class="results">
+          ❌
+          <i>no cancelled date</i>
+        </code>
+      </div>
+      <div class="tasks-modal-section tasks-modal-buttons">
+        <button type="submit" class="mod-cta">Apply</button>
+        <button type="button">Cancel</button>
+      </div>
+    </form>
+  </div>
+</div>
diff --git a/tests/ui/EditTask.test.ts b/tests/ui/EditTask.test.ts
index 7baa6183..fa65310c 100644
--- a/tests/ui/EditTask.test.ts
+++ b/tests/ui/EditTask.test.ts
@@ -1,18 +1,20 @@
 /**
  * @jest-environment jsdom
  */
-import { type RenderResult, fireEvent, render } from '@testing-library/svelte';
 import { describe, expect, it } from '@jest/globals';
+import { type RenderResult, fireEvent, render } from '@testing-library/svelte';
 import moment from 'moment';
 import { taskFromLine } from '../../src/Commands/CreateOrEditTaskParser';
-import type { Task } from '../../src/Task/Task';
-import EditTask from '../../src/ui/EditTask.svelte';
-import { DateFallback } from '../../src/Task/DateFallback';
 import { GlobalFilter } from '../../src/Config/GlobalFilter';
 import { resetSettings, updateSettings } from '../../src/Config/Settings';
+import { StatusRegistry } from '../../src/Statuses/StatusRegistry';
+import { DateFallback } from '../../src/Task/DateFallback';
+import type { Task } from '../../src/Task/Task';
+import EditTask from '../../src/ui/EditTask.svelte';
+import { verifyWithFileExtension } from '../TestingTools/ApprovalTestHelpers';
 import { verifyAllCombinations3Async } from '../TestingTools/CombinationApprovalsAsync';
+import { prettifyHTML } from '../TestingTools/HTMLHelpers';
 import { TaskBuilder } from '../TestingTools/TaskBuilder';
-import { StatusRegistry } from '../../src/Statuses/StatusRegistry';
 
 window.moment = moment;
 /**
@@ -520,3 +522,15 @@ describe('Exhaustive editing', () => {
         );
     });
 });
+
+describe('Edit Modal HTML snapshot tests', () => {
+    it('should match snapshot', () => {
+        const task = taskFromLine({ line: '- [ ] absolutely to do', path: '' });
+        const onSubmit = () => {};
+        const allTasks = [task];
+        const { container } = renderAndCheckModal(task, onSubmit, allTasks);
+
+        const prettyHTML = prettifyHTML(container.innerHTML);
+        verifyWithFileExtension(prettyHTML, 'html');
+    });
+});