Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2024 (2023-06-06 11:30:56)

test: add group sort order tests
<!--- Provide a general summary of your changes in the Title above -->

# Description

Test group headings order for fields:
- `Due`
- `Done`
- `Start`
- `Scheduled`
- `Created`
- `Happens`

- `Recurrence`
- `Recurring`

-  `Path`
- `Root`
- `Backlink`
- `Folder`
- `Filename`

- `StatusType`

## Motivation and Context

Following #1999 , add test for the rest of the fields.

## Types of changes

Internal changes:

- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)

## Checklist

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [ ] My change requires a change to the documentation.
- [ ] I have [updated the documentation](https://publish.obsidian.md/tasks-contributing/Documentation/About+Documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://publish.obsidian.md/tasks-contributing) and
[Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md).
Put an x in the boxes to confirm you agree.
-->

- [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/Grouping.md b/docs/Queries/Grouping.md
index 871dbe47..4224d3bb 100644
--- a/docs/Queries/Grouping.md
+++ b/docs/Queries/Grouping.md
@@ -10,16 +10,16 @@ publish: true
 
 This page is long. Here are some links to the main sections:
 
-- [[#Basics]
-- [[#Group by Task Statuses]
-- [[#Group by Dates in Tasks]
-- [[#Group by Other Task Properties]
-- [[#Group by File Properties]
-- [[#Multiple groups]
-- [[#Refining groups]
-- [[#Notes]
-- [[#Screenshots]
-- [[#Examples]
+- [[#Basics]]
+- [[#Group by Task Statuses]]
+- [[#Group by Dates in Tasks]]
+- [[#Group by Other Task Properties]]
+- [[#Group by File Properties]]
+- [[#Multiple groups]]
+- [[#Refining groups]]
+- [[#Notes]]
+- [[#Screenshots]]
+- [[#Examples]]
 
 ---
 
diff --git a/docs/_meta/templates/sample mermaid diagram.md b/docs/_meta/templates/sample mermaid diagram.md
new file mode 100644
index 00000000..f52ebabe
--- /dev/null
+++ b/docs/_meta/templates/sample mermaid diagram.md
@@ -0,0 +1,19 @@
+```mermaid
+flowchart LR
+1[Input Data]
+2[Updated Code]
+3{Output the same?}
+1 --> 2
+2 --> 3
+
+4[Pass]
+3---|Yes|4
+linkStyle 2 stroke:green
+
+5[Fail]
+6[Open diff tool to show changes]
+3---|No|5
+5 --> 6
+
+linkStyle 3 stroke:red
+```
diff --git a/src/Query/TaskGroupingTree.ts b/src/Query/TaskGroupingTree.ts
index 01611c7f..83c1c4ef 100644
--- a/src/Query/TaskGroupingTree.ts
+++ b/src/Query/TaskGroupingTree.ts
@@ -67,6 +67,14 @@ export class TaskGroupingTree {
                     // Get properties of a task for the grouper
                     // The returned string is rendered, so special Markdown characters will be escaped
                     const groupNames = grouper.grouper(task);
+
+                    if (groupNames.length === 0) {
+                        // Create a fake empty group-name so that we can add these tasks
+                        // to the tree. This group will be displayed with no heading.
+                        // Without this, they would be lost and not displayed at all the query results.
+                        groupNames.push('');
+                    }
+
                     for (const groupName of groupNames) {
                         let child = currentTreeNode.children.get(groupName);
                         if (child === undefined) {
diff --git a/src/Query/TaskGroups.ts b/src/Query/TaskGroups.ts
index 51037e80..dca1c764 100644
--- a/src/Query/TaskGroups.ts
+++ b/src/Query/TaskGroups.ts
@@ -153,10 +153,20 @@ export class TaskGroups {
             group.applyTaskLimit(limit);
         });
 
-        this.calculateTotalTaskCount();
+        this.recalculateTotalTaskCount();
     }
 
-    private calculateTotalTaskCount() {
+    /**
+     * Update the stored task count to represent the number of unique tasks
+     * across all the groups.
+     *
+     * In normal operation this is only useful after a limit to group size
+     * has been applied.
+     *
+     * However, it is also useful for test code to be able to ensure consistency
+     * of task count after the object has been initially constructed.
+     */
+    public recalculateTotalTaskCount() {
         let concatenatedTasks: Task[] = [];
 
         this._groups.forEach((group) => {


Test patch:
diff --git a/tests/Query/Filter/BacklinkField.test.ts b/tests/Query/Filter/BacklinkField.test.ts
index 375a64a9..f5abb5c9 100644
--- a/tests/Query/Filter/BacklinkField.test.ts
+++ b/tests/Query/Filter/BacklinkField.test.ts
@@ -1,5 +1,5 @@
 import { BacklinkField } from '../../../src/Query/Filter/BacklinkField';
-import { fromLine } from '../../TestHelpers';
+import { SampleTasks, fromLine } from '../../TestHelpers';
 
 describe('backlink', () => {
     it('should provide the backlink', () => {
@@ -65,4 +65,20 @@ describe('grouping by backlink', () => {
             expect(grouper(fromLine({ line: t, path: path, precedingHeader: heading }))).toEqual(groups);
         },
     );
+
+    it('should sort groups for BacklinkField', () => {
+        // Arrange
+        const tasks = SampleTasks.withAllRootsPathsHeadings();
+        const grouper = new BacklinkField().createNormalGrouper();
+
+        // Assert
+        expect({ grouper, tasks }).groupHeadingsToBe([
+            '\\_c\\_',
+            '\\_c\\_ > heading _italic text_',
+            'a\\_b\\_c',
+            'c',
+            'c > heading',
+            'Unknown Location',
+        ]);
+    });
 });
diff --git a/tests/Query/Filter/CreatedDateField.test.ts b/tests/Query/Filter/CreatedDateField.test.ts
index b3a535d5..899542ac 100644
--- a/tests/Query/Filter/CreatedDateField.test.ts
+++ b/tests/Query/Filter/CreatedDateField.test.ts
@@ -11,6 +11,7 @@ import {
     expectTaskComparesBefore,
     expectTaskComparesEqual,
 } from '../../CustomMatchers/CustomMatchersForSorting';
+import { SampleTasks } from '../../TestHelpers';
 
 window.moment = moment;
 
@@ -105,4 +106,17 @@ describe('grouping by created date', () => {
         expect(grouper.grouper(taskWithDate)).toEqual(['1970-01-01 Thursday']);
         expect(grouper.grouper(taskWithoutDate)).toEqual(['No created date']);
     });
+
+    it('should sort groups for CreatedDateField', () => {
+        const grouper = new CreatedDateField().createNormalGrouper();
+        const tasks = SampleTasks.withAllRepresentativeCreatedDates();
+
+        expect({ grouper, tasks }).groupHeadingsToBe([
+            '2023-05-30 Tuesday',
+            '2023-05-31 Wednesday',
+            '2023-06-01 Thursday',
+            'Invalid date',
+            'No created date',
+        ]);
+    });
 });
diff --git a/tests/Query/Filter/DoneDateField.test.ts b/tests/Query/Filter/DoneDateField.test.ts
index 18441cb6..9d6e266c 100644
--- a/tests/Query/Filter/DoneDateField.test.ts
+++ b/tests/Query/Filter/DoneDateField.test.ts
@@ -7,6 +7,7 @@ import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { testFilter } from '../../TestingTools/FilterTestHelpers';
 import { expectTaskComparesAfter, expectTaskComparesBefore } from '../../CustomMatchers/CustomMatchersForSorting';
+import { SampleTasks } from '../../TestHelpers';
 
 window.moment = moment;
 
@@ -102,4 +103,17 @@ describe('grouping by done date', () => {
         expect(grouper.grouper(taskWithDate)).toEqual(['1970-01-01 Thursday']);
         expect(grouper.grouper(taskWithoutDate)).toEqual(['No done date']);
     });
+
+    it('should sort groups for DoneDateField', () => {
+        const grouper = new DoneDateField().createNormalGrouper();
+        const tasks = SampleTasks.withAllRepresentativeDoneDates();
+
+        expect({ grouper, tasks }).groupHeadingsToBe([
+            '2023-05-30 Tuesday',
+            '2023-05-31 Wednesday',
+            '2023-06-01 Thursday',
+            'Invalid date',
+            'No done date',
+        ]);
+    });
 });
diff --git a/tests/Query/Filter/DueDateField.test.ts b/tests/Query/Filter/DueDateField.test.ts
index 48418e8f..5be7a88f 100644
--- a/tests/Query/Filter/DueDateField.test.ts
+++ b/tests/Query/Filter/DueDateField.test.ts
@@ -2,7 +2,6 @@
  * @jest-environment jsdom
  */
 import moment from 'moment';
-import type { Task } from 'Task';
 import { DueDateField } from '../../../src/Query/Filter/DueDateField';
 import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
@@ -14,6 +13,7 @@ import {
 } from '../../CustomMatchers/CustomMatchersForSorting';
 import { Query } from '../../../src/Query/Query';
 import { MarkdownTable } from '../../TestingTools/VerifyMarkdownTable';
+import { SampleTasks } from '../../TestHelpers';
 
 window.moment = moment;
 
@@ -535,7 +535,7 @@ describe('grouping by due date', () => {
 
     it('should sort groups for DueDateField', () => {
         const grouper = new DueDateField().createNormalGrouper();
-        const tasks = withAllRepresentativeDueDates();
+        const tasks = SampleTasks.withAllRepresentativeDueDates();
 
         expect({ grouper, tasks }).groupHeadingsToBe([
             '2023-05-30 Tuesday',
@@ -546,13 +546,3 @@ describe('grouping by due date', () => {
         ]);
     });
 });
-
-function withAllRepresentativeDueDates(): Task[] {
-    const dates = ['2023-05-30', '2023-05-31', '2023-06-01', '2023-02-32', null];
-
-    const tasks = dates.map((date) => {
-        return new TaskBuilder().dueDate(date).build();
-    });
-
-    return tasks;
-}
diff --git a/tests/Query/Filter/FilenameField.test.ts b/tests/Query/Filter/FilenameField.test.ts
index 19dabb94..d29a5997 100644
--- a/tests/Query/Filter/FilenameField.test.ts
+++ b/tests/Query/Filter/FilenameField.test.ts
@@ -1,7 +1,7 @@
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { FilenameField } from '../../../src/Query/Filter/FilenameField';
 import * as CustomMatchersForSorting from '../../CustomMatchers/CustomMatchersForSorting';
-import { fromLine } from '../../TestHelpers';
+import { SampleTasks, fromLine } from '../../TestHelpers';
 
 describe('filename', () => {
     it('should provide access to the file name with extension', () => {
@@ -119,4 +119,13 @@ describe('grouping by filename', () => {
         // Assert
         expect(grouper(fromLine({ line: taskLine, path: path }))).toEqual(groups);
     });
+
+    it('should sort groups for FilenameField', () => {
+        // Arrange
+        const tasks = SampleTasks.withAllRootsPathsHeadings();
+        const grouper = new FilenameField().createNormalGrouper();
+
+        // Assert
+        expect({ grouper, tasks }).groupHeadingsToBe(['[[_c_]]', '[[a_b_c]]', '[[c]]', 'Unknown Location']);
+    });
 });
diff --git a/tests/Query/Filter/FolderField.test.ts b/tests/Query/Filter/FolderField.test.ts
index fdf5c29a..3d126516 100644
--- a/tests/Query/Filter/FolderField.test.ts
+++ b/tests/Query/Filter/FolderField.test.ts
@@ -1,5 +1,5 @@
 import { FolderField } from '../../../src/Query/Filter/FolderField';
-import { fromLine } from '../../TestHelpers';
+import { SampleTasks, fromLine } from '../../TestHelpers';
 
 describe('folder', () => {
     it('should provide access to the folder', () => {
@@ -48,4 +48,13 @@ describe('grouping by folder', () => {
         // Assert
         expect(grouper(fromLine({ line: taskLine, path: path }))).toEqual(groups);
     });
+
+    it('should sort groups for FolderField', () => {
+        // Arrange
+        const tasks = SampleTasks.withAllRootsPathsHeadings();
+        const grouper = new FolderField().createNormalGrouper();
+
+        // Assert
+        expect({ grouper, tasks }).groupHeadingsToBe(['/', 'a/b/', 'a/d/', 'e/d/']);
+    });
 });
diff --git a/tests/Query/Filter/HappensDateField.test.ts b/tests/Query/Filter/HappensDateField.test.ts
index e1617ffb..ab01563c 100644
--- a/tests/Query/Filter/HappensDateField.test.ts
+++ b/tests/Query/Filter/HappensDateField.test.ts
@@ -6,7 +6,7 @@ import { HappensDateField } from '../../../src/Query/Filter/HappensDateField';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { testFilter } from '../../TestingTools/FilterTestHelpers';
 import * as CustomMatchersForSorting from '../../CustomMatchers/CustomMatchersForSorting';
-import { fromLine } from '../../TestHelpers';
+import { SampleTasks, fromLine } from '../../TestHelpers';
 
 window.moment = moment;
 
@@ -155,4 +155,21 @@ describe('grouping by happens date', () => {
         // Assert
         expect(grouper.grouper(fromLine({ line: taskLine }))).toEqual(expectedResult);
     });
+
+    it('should sort groups for HappensDateField', () => {
+        const grouper = new HappensDateField().createNormalGrouper();
+        const tasks = [
+            ...SampleTasks.withAllRepresentativeDueDates(),
+            ...SampleTasks.withAllRepresentativeScheduledDates(),
+            ...SampleTasks.withAllRepresentativeStartDates(),
+        ];
+
+        expect({ grouper, tasks }).groupHeadingsToBe([
+            '2023-05-30 Tuesday',
+            '2023-05-31 Wednesday',
+            '2023-06-01 Thursday',
+            'Invalid date',
+            'No happens date',
+        ]);
+    });
 });
diff --git a/tests/Query/Filter/HeadingField.test.ts b/tests/Query/Filter/HeadingField.test.ts
index 8c190704..096ddf26 100644
--- a/tests/Query/Filter/HeadingField.test.ts
+++ b/tests/Query/Filter/HeadingField.test.ts
@@ -3,7 +3,7 @@ import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { testFilter } from '../../TestingTools/FilterTestHelpers';
 import * as CustomMatchersForSorting from '../../CustomMatchers/CustomMatchersForSorting';
-import { fromLine } from '../../TestHelpers';
+import { SampleTasks, fromLine } from '../../TestHelpers';
 
 function testTaskFilterForHeading(filter: FilterOrErrorMessage, precedingHeader: string | null, expected: boolean) {
     const builder = new TaskBuilder();
@@ -110,4 +110,19 @@ describe('grouping by heading', () => {
             expect(grouper(fromLine({ line: taskLine, precedingHeader: header }))).toEqual(groups);
         },
     );
+
+    it('should sort groups for HeadingField', () => {
+        // Arrange
+        const tasks = SampleTasks.withAllRootsPathsHeadings();
+        const grouper = new HeadingField().createNormalGrouper();
+
+        // Assert
+        expect({ grouper, tasks }).groupHeadingsToBe([
+            '(No heading)',
+            'a_b_c',
+            'c',
+            'heading',
+            'heading _italic text_',
+        ]);
+    });
 });
diff --git a/tests/Query/Filter/PathField.test.ts b/tests/Query/Filter/PathField.test.ts
index 6f4f9e5a..e97ddaa6 100644
--- a/tests/Query/Filter/PathField.test.ts
+++ b/tests/Query/Filter/PathField.test.ts
@@ -7,7 +7,7 @@ import {
     expectTaskComparesBefore,
     expectTaskComparesEqual,
 } from '../../CustomMatchers/CustomMatchersForSorting';
-import { fromLine } from '../../TestHelpers';
+import { SampleTasks, fromLine } from '../../TestHelpers';
 
 function testTaskFilterForTaskWithPath(filter: FilterOrErrorMessage, path: string, expected: boolean) {
     const builder = new TaskBuilder();
@@ -163,4 +163,20 @@ describe('grouping by path', () => {
         // Assert
         expect(grouper(fromLine({ line: taskLine, path: path }))).toEqual(groups);
     });
+
+    it('should sort groups for PathField', () => {
+        // Arrange
+        const tasks = SampleTasks.withAllRootsPathsHeadings();
+        const grouper = new PathField().createNormalGrouper();
+
+        // Assert
+        expect({ grouper, tasks }).groupHeadingsToBe([
+            // Why there is no path for empty path?
+            'a/b/\\_c\\_',
+            'a/b/c',
+            'a/d/c',
+            'a\\_b\\_c',
+            'e/d/c',
+        ]);
+    });
 });
diff --git a/tests/Query/Filter/PriorityField.test.ts b/tests/Query/Filter/PriorityField.test.ts
index 003203c6..b7c28b4a 100644
--- a/tests/Query/Filter/PriorityField.test.ts
+++ b/tests/Query/Filter/PriorityField.test.ts
@@ -1,8 +1,8 @@
-import { Priority, Task } from '../../../src/Task';
+import { Priority } from '../../../src/Task';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { testFilter } from '../../TestingTools/FilterTestHelpers';
 import { PriorityField } from '../../../src/Query/Filter/PriorityField';
-import { fromLine } from '../../TestHelpers';
+import { SampleTasks, fromLine } from '../../TestHelpers';
 
 import {
     expectTaskComparesAfter,
@@ -229,7 +229,7 @@ describe('grouping by priority', () => {
 
     it('should sort groups for PriorityField', () => {
         const grouper = new PriorityField().createNormalGrouper();
-        const tasks = withAllPriorities();
+        const tasks = SampleTasks.withAllPriorities();
 
         expect({ grouper, tasks }).groupHeadingsToBe([
             'Priority 0: Highest',
@@ -241,18 +241,3 @@ describe('grouping by priority', () => {
         ]);
     });
 });
-
-export function withAllPriorities(): Task[] {
-    const tasks: Task[] = [];
-    const allPriorities = Object.values(Priority);
-    allPriorities.forEach((priority) => {
-        // This description is chosen to be useful for including tasks in user docs, so
-        // changing it will change documentation and sample vault content.
-        const priorityName = PriorityField.priorityNameUsingNormal(priority);
-        const description = `#task ${priorityName} priority`;
-
-        const task = new TaskBuilder().priority(priority).description(description).build();
-        tasks.push(task);
-    });
-    return tasks;
-}
diff --git a/tests/Query/Filter/RecurrenceField.test.ts b/tests/Query/Filter/RecurrenceField.test.ts
index 476a0351..36cfb239 100644
--- a/tests/Query/Filter/RecurrenceField.test.ts
+++ b/tests/Query/Filter/RecurrenceField.test.ts
@@ -6,7 +6,7 @@ import moment from 'moment';
 import { RecurrenceField } from '../../../src/Query/Filter/RecurrenceField';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { RecurrenceBuilder } from '../../TestingTools/RecurrenceBuilder';
-import { fromLine } from '../../TestHelpers';
+import { SampleTasks, fromLine } from '../../TestHelpers';
 
 window.moment = moment;
 
@@ -93,4 +93,23 @@ describe('grouping by recurrence', () => {
         // Assert
         expect(grouper(fromLine({ line: taskLine }))).toEqual(groups);
     });
+
+    it('should sort groups for RecurrenceField', () => {
+        const grouper = new RecurrenceField().createNormalGrouper();
+        const tasks = SampleTasks.withAllRecurrences();
+
+        expect({ grouper, tasks }).groupHeadingsToBe([
+            'every 4 months on the 3rd Wednesday',
+            'every 4 weeks',
+            'every 6 days',
+            'every 8 days',
+            'every 8 days when done',
+            'every day',
+            'every month',
+            'every week',
+            'every week on Tuesday',
+            'every week on Tuesday when done',
+            'None',
+        ]);
+    });
 });
diff --git a/tests/Query/Filter/RecurringField.test.ts b/tests/Query/Filter/RecurringField.test.ts
index 2b84c574..67e65799 100644
--- a/tests/Query/Filter/RecurringField.test.ts
+++ b/tests/Query/Filter/RecurringField.test.ts
@@ -5,7 +5,7 @@ import moment from 'moment';
 import { RecurringField } from '../../../src/Query/Filter/RecurringField';
 import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
 import { testTaskFilter } from '../../TestingTools/FilterTestHelpers';
-import { fromLine } from '../../TestHelpers';
+import { SampleTasks, fromLine } from '../../TestHelpers';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import {
     expectTaskComparesAfter,
@@ -101,4 +101,11 @@ describe('grouping by recurring', () => {
         // Assert
         expect(grouper(fromLine({ line: taskLine }))).toEqual(groups);
     });
+
+    it('should sort groups for RecurringField', () => {
+        const grouper = new RecurringField().createNormalGrouper();
+        const tasks = SampleTasks.withAllRecurrences();
+
+        expect({ grouper, tasks }).groupHeadingsToBe(['Not Recurring', 'Recurring']);
+    });
 });
diff --git a/tests/Query/Filter/RootField.test.ts b/tests/Query/Filter/RootField.test.ts
index f5e04f2f..eb378db9 100644
--- a/tests/Query/Filter/RootField.test.ts
+++ b/tests/Query/Filter/RootField.test.ts
@@ -1,5 +1,5 @@
 import { RootField } from '../../../src/Query/Filter/RootField';
-import { fromLine } from '../../TestHelpers';
+import { SampleTasks, fromLine } from '../../TestHelpers';
 
 describe('root', () => {
     it('should provide access to root folder name with trailing slash', () => {
@@ -51,4 +51,13 @@ describe('grouping by root', () => {
         // Assert
         expect(grouper(fromLine({ line: taskLine, path: path }))).toEqual(groups);
     });
+
+    it('should sort groups for FilenameField', () => {
+        // Arrange
+        const tasks = SampleTasks.withAllRootsPathsHeadings();
+        const grouper = new RootField().createNormalGrouper();
+
+        // Assert
+        expect({ grouper, tasks }).groupHeadingsToBe(['/', 'a/', 'e/']);
+    });
 });
diff --git a/tests/Query/Filter/ScheduledDateField.test.ts b/tests/Query/Filter/ScheduledDateField.test.ts
index e41735d4..bec59c7d 100644
--- a/tests/Query/Filter/ScheduledDateField.test.ts
+++ b/tests/Query/Filter/ScheduledDateField.test.ts
@@ -5,6 +5,7 @@ import moment from 'moment';
 import { ScheduledDateField } from '../../../src/Query/Filter/ScheduledDateField';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { expectTaskComparesAfter, expectTaskComparesBefore } from '../../CustomMatchers/CustomMatchersForSorting';
+import { SampleTasks } from '../../TestHelpers';
 
 window.moment = moment;
 
@@ -56,4 +57,17 @@ describe('grouping by scheduled date', () => {
         expect(grouper.grouper(taskWithDate)).toEqual(['1970-01-01 Thursday']);
         expect(grouper.grouper(taskWithoutDate)).toEqual(['No scheduled date']);
     });
+
+    it('should sort groups for ScheduledDateField', () => {
+        const grouper = new ScheduledDateField().createNormalGrouper();
+        const tasks = SampleTasks.withAllRepresentativeScheduledDates();
+
+        expect({ grouper, tasks }).groupHeadingsToBe([
+            '2023-05-30 Tuesday',
+            '2023-05-31 Wednesday',
+            '2023-06-01 Thursday',
+            'Invalid date',
+            'No scheduled date',
+        ]);
+    });
 });
diff --git a/tests/Query/Filter/StartDateField.test.ts b/tests/Query/Filter/StartDateField.test.ts
index 271bcbe1..13475aaf 100644
--- a/tests/Query/Filter/StartDateField.test.ts
+++ b/tests/Query/Filter/StartDateField.test.ts
@@ -5,6 +5,7 @@ import moment from 'moment';
 import { StartDateField } from '../../../src/Query/Filter/StartDateField';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { expectTaskComparesAfter, expectTaskComparesBefore } from '../../CustomMatchers/CustomMatchersForSorting';
+import { SampleTasks } from '../../TestHelpers';
 
 window.moment = moment;
 
@@ -72,4 +73,17 @@ describe('grouping by start date', () => {
         expect(grouper.grouper(taskWithDate)).toEqual(['1970-01-01 Thursday']);
         expect(grouper.grouper(taskWithoutDate)).toEqual(['No start date']);
     });
+
+    it('should sort groups for StartDateField', () => {
+        const grouper = new StartDateField().createNormalGrouper();
+        const tasks = SampleTasks.withAllRepresentativeStartDates();
+
+        expect({ grouper, tasks }).groupHeadingsToBe([
+            '2023-05-30 Tuesday',
+            '2023-05-31 Wednesday',
+            '2023-06-01 Thursday',
+            'Invalid date',
+            'No start date',
+        ]);
+    });
 });
diff --git a/tests/Query/Filter/StatusField.test.ts b/tests/Query/Filter/StatusField.test.ts
index 14e827c2..30e43023 100644
--- a/tests/Query/Filter/StatusField.test.ts
+++ b/tests/Query/Filter/StatusField.test.ts
@@ -101,4 +101,12 @@ describe('grouping by status', () => {
         // Assert
         expect(grouper(fromLine({ line: taskLine }))).toEqual(groups);
     });
+
+    it('should sort groups for StatusField', () => {
+        const grouper = new StatusField().createNormalGrouper();
+        const taskLines = ['- [ ] a', '- [x] a'];
+        const tasks = taskLines.map((taskLine) => fromLine({ line: taskLine }));
+
+        expect({ grouper, tasks }).groupHeadingsToBe(['Done', 'Todo']);
+    });
 });
diff --git a/tests/Query/Filter/StatusNameField.test.ts b/tests/Query/Filter/StatusNameField.test.ts
index a56f2ac1..584e7949 100644
--- a/tests/Query/Filter/StatusNameField.test.ts
+++ b/tests/Query/Filter/StatusNameField.test.ts
@@ -1,20 +1,17 @@
 import { StatusNameField } from '../../../src/Query/Filter/StatusNameField';
-import * as TestHelpers from '../../TestHelpers';
 import {
     expectTaskComparesAfter,
     expectTaskComparesBefore,
     expectTaskComparesEqual,
 } from '../../CustomMatchers/CustomMatchersForSorting';
-import type { Task } from '../../../src/Task';
-import { TaskBuilder } from '../../TestingTools/TaskBuilder';
-import { Status } from '../../../src/Status';
+import { SampleTasks, fromLine } from '../../TestHelpers';
 
 // Abbreviated names so that the markdown text is aligned
-const todoTask = TestHelpers.fromLine({ line: '- [ ] Xxx' });
-const inprTask = TestHelpers.fromLine({ line: '- [/] Xxx' });
-const doneTask = TestHelpers.fromLine({ line: '- [x] Xxx' });
-const cancTask = TestHelpers.fromLine({ line: '- [-] Xxx' });
-const unknTask = TestHelpers.fromLine({ line: '- [%] Xxx' });
+const todoTask = fromLine({ line: '- [ ] Xxx' });
+const inprTask = fromLine({ line: '- [/] Xxx' });
+const doneTask = fromLine({ line: '- [x] Xxx' });
+const cancTask = fromLine({ line: '- [-] Xxx' });
+const unknTask = fromLine({ line: '- [%] Xxx' });
 
 describe('status.name', () => {
     it('value', () => {
@@ -103,24 +100,8 @@ describe('grouping by status.name', () => {
 
     it('should sort groups for StatusNameField', () => {
         const grouper = new StatusNameField().createNormalGrouper();
-        const tasks = withAllStatuses();
+        const tasks = SampleTasks.withAllStatuses();
 
         expect({ grouper, tasks }).groupHeadingsToBe(['Cancelled', 'Done', 'EMPTY', 'In Progress', 'Todo']);
     });
 });
-
-function withAllStatuses(): Task[] {
-    const statuses = [
-        Status.makeCancelled(),
-        Status.makeDone(),
-        Status.makeEmpty(),
-        Status.makeInProgress(),
-        Status.makeTodo(),
-    ];
-
-    const tasks = statuses.map((status) => {
-        return new TaskBuilder().status(status).build();
-    });
-
-    return tasks;
-}
diff --git a/tests/Query/Filter/StatusTypeField.test.ts b/tests/Query/Filter/StatusTypeField.test.ts
index 6feb781b..4804c166 100644
--- a/tests/Query/Filter/StatusTypeField.test.ts
+++ b/tests/Query/Filter/StatusTypeField.test.ts
@@ -1,5 +1,4 @@
 import { StatusTypeField } from '../../../src/Query/Filter/StatusTypeField';
-import * as TestHelpers from '../../TestHelpers';
 import {
     expectTaskComparesAfter,
     expectTaskComparesBefore,
@@ -9,13 +8,14 @@ import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { StatusType } from '../../../src/StatusConfiguration';
 import { Status } from '../../../src/Status';
 import * as FilterParser from '../../../src/Query/FilterParser';
+import { SampleTasks, fromLine } from '../../TestHelpers';
 
 // Abbreviated names so that the markdown text is aligned
-const todoTask = TestHelpers.fromLine({ line: '- [ ] Todo' });
-const inprTask = TestHelpers.fromLine({ line: '- [/] In progress' });
-const doneTask = TestHelpers.fromLine({ line: '- [x] Done' });
-const cancTask = TestHelpers.fromLine({ line: '- [-] Cancelled' });
-const unknTask = TestHelpers.fromLine({ line: '- [%] Unknown' });
+const todoTask = fromLine({ line: '- [ ] Todo' });
+const inprTask = fromLine({ line: '- [/] In progress' });
+const doneTask = fromLine({ line: '- [x] Done' });
+const cancTask = fromLine({ line: '- [-] Cancelled' });
+const unknTask = fromLine({ line: '- [%] Unknown' });
 const non_Task = new TaskBuilder()
     .statusValues('^', 'non-task', 'x', false, StatusType.NON_TASK)
     .description('Non-task')
@@ -160,4 +160,18 @@ describe('grouping by status.type', () => {
         expect(grouper.grouper(non_Task)).toEqual(['5 NON_TASK']);
         expect(grouper.grouper(emptTask)).toEqual(['6 EMPTY']); // won't be seen by users
     });
+
+    it('should sort groups for StatusTypeField', () => {
+        const grouper = new StatusTypeField().createNormalGrouper();
+        const tasks = SampleTasks.withAllStatusTypes();
+
+        expect({ grouper, tasks }).groupHeadingsToBe([
+            '1 IN_PROGRESS',
+            '2 TODO',
+            '3 DONE',
+            '4 CANCELLED',
+            '5 NON_TASK',
+            '6 EMPTY',
+        ]);
+    });
 });
diff --git a/tests/Query/Filter/TagsField.test.ts b/tests/Query/Filter/TagsField.test.ts
index 7888fcc4..36415a56 100644
--- a/tests/Query/Filter/TagsField.test.ts
+++ b/tests/Query/Filter/TagsField.test.ts
@@ -673,4 +673,20 @@ describe('grouping by tag', () => {
             "
         `);
     });
+
+    it('should sort groups for TagsField', () => {
+        const grouper = new TagsField().createNormalGrouper();
+        const taskLines = [
+            '- [ ] a #tag1',
+            '- [ ] a #tag1/tag3',
+            '- [ ] a #tag2',
+            '- [ ] a #tag2/tag4',
+            '- [ ] a #tag1 #tag2',
+            '- [ ] a #tag1/tag3 #tag2/tag4',
+            '- [ ] a',
+        ];
+        const tasks = taskLines.map((taskLine) => fromLine({ line: taskLine }));
+
+        expect({ grouper, tasks }).groupHeadingsToBe(['(No tags)', '#tag1', '#tag1/tag3', '#tag2', '#tag2/tag4']);
+    });
 });
diff --git a/tests/Query/Filter/UrgencyField.test.ts b/tests/Query/Filter/UrgencyField.test.ts
index d079bc50..aa2dc751 100644
--- a/tests/Query/Filter/UrgencyField.test.ts
+++ b/tests/Query/Filter/UrgencyField.test.ts
@@ -96,4 +96,12 @@ describe('grouping by urgency', () => {
         // Assert
         expect(grouper(fromLine({ line: taskLine }))).toEqual(groups);
     });
+
+    it('should sort groups for UrgencyField', () => {
+        const grouper = new UrgencyField().createNormalGrouper();
+        const taskLines = ['- [ ] a ⏫', '- [ ] a 🔼', '- [ ] a', '- [ ] a 🔽'];
+        const tasks = taskLines.map((taskLine) => fromLine({ line: taskLine }));
+
+        expect({ grouper, tasks }).groupHeadingsToBe(['0.00', '1.95', '3.90', '6.00']);
+    });
 });
diff --git a/tests/TaskGroups.test.ts b/tests/TaskGroups.test.ts
index 33b036ee..5202b2f8 100644
--- a/tests/TaskGroups.test.ts
+++ b/tests/TaskGroups.test.ts
@@ -3,7 +3,7 @@
  */
 import moment from 'moment';
 import { FilenameField } from '../src/Query/Filter/FilenameField';
-import type { Grouper } from '../src/Query/Grouper';
+import { Grouper, type GrouperFunction } from '../src/Query/Grouper';
 import type { Task } from '../src/Task';
 import { PathField } from '../src/Query/Filter/PathField';
 import { TagsField } from '../src/Query/Filter/TagsField';
@@ -242,6 +242,46 @@ describe('Grouping tasks', () => {
         `);
     });
 
+    it('should retain tasks with no group name', () => {
+        const a = fromLine({
+            line: '- [ ] Task with a tag #group1',
+        });
+        const b = fromLine({
+            line: '- [ ] Task without a tag',
+        });
+        const inputs = [a, b];
+
+        const groupByTags: GrouperFunction = (task: Task) => task.tags;
+        const grouper = new Grouper('custom tag grouper', groupByTags, false);
+        const groups = new TaskGroups([grouper], inputs);
+
+        expect(groups.totalTasksCount()).toEqual(2);
+
+        // Force a recalculation of the task count, to ensure no
+        // tasks were lost in the grouping:
+        groups.recalculateTotalTaskCount();
+        expect(groups.totalTasksCount()).toEqual(2);
+
+        expect(groups.toString()).toMatchInlineSnapshot(`
+            "Groupers (if any):
+            - custom tag grouper
+
+            Group names: []
+            - [ ] Task without a tag
+
+            ---
+
+            Group names: [#group1]
+            #### [custom tag grouper] #group1
+            - [ ] Task with a tag #group1
+
+            ---
+
+            2 tasks
+            "
+        `);
+    });
+
     it('should create nested headings if multiple groups used even if one is reversed', () => {
         // Arrange
         const t1 = fromLine({
diff --git a/tests/TaskSerializer/DocsSamplesForTaskFormats.test.ts b/tests/TaskSerializer/DocsSamplesForTaskFormats.test.ts
index 1e8c7888..b1a8b80c 100644
--- a/tests/TaskSerializer/DocsSamplesForTaskFormats.test.ts
+++ b/tests/TaskSerializer/DocsSamplesForTaskFormats.test.ts
@@ -1,11 +1,11 @@
 import { TASK_FORMATS, updateSettings } from '../../src/Config/Settings';
 import { verifyMarkdown, verifyMarkdownForDocs } from '../TestingTools/VerifyMarkdownTable';
-import { withAllPriorities } from '../Query/Filter/PriorityField.test';
+import { SampleTasks } from '../TestHelpers';
 
 describe('Serializer', () => {
     describe('Priorities', () => {
         function allPriorityLines() {
-            const tasks = withAllPriorities().reverse();
+            const tasks = SampleTasks.withAllPriorities().reverse();
             return tasks.map((t) => t.toFileLineString()).join('\n');
         }
 
diff --git a/tests/TestHelpers.ts b/tests/TestHelpers.ts
index 29046fb9..5316e25c 100644
--- a/tests/TestHelpers.ts
+++ b/tests/TestHelpers.ts
@@ -1,5 +1,10 @@
-import { Task } from '../src/Task';
+import { Priority, Task } from '../src/Task';
 import { TaskLocation } from '../src/TaskLocation';
+import { Status } from './../src/Status';
+import { StatusType } from './../src/StatusConfiguration';
+import { PriorityField } from './../src/Query/Filter/PriorityField';
+import { TaskBuilder } from './TestingTools/TaskBuilder';
+import { Recurrence } from './../src/Recurrence';
 
 export function fromLine({
     line,
@@ -32,3 +37,165 @@ export function createTasksFromMarkdown(tasksAsMarkdown: string, path: string, p
     }
     return tasks;
 }
+
+const representativeDates = ['2023-05-30', '2023-05-31', '2023-06-01', '2023-02-32', null];
+
+export class SampleTasks {
+    public static withAllRecurrences(): Task[] {
+        const recurrenceRules = [
+            // Months
+            'every 4 months on the 3rd Wednesday',
+            'every month',
+            'every second of month',
+            'every second of month when done',
+
+            // Weeks
+            'every Tuesday',
+            'every Tuesday when done',
+            'every week',
+            'every 3rd Thursday',
+            'every 4 weeks',
+
+            // Days
+            'every 6 days',
+            'every 8 days',
+            'every 8 days when done',
+            'every day',
+            '',
+        ];
+
+        const tasks = recurrenceRules.map((recurrenceRule) => {
+            return new TaskBuilder()
+                .recurrence(
+                    Recurrence.fromText({
+                        recurrenceRuleText: recurrenceRule,
+                        startDate: null,
+                        scheduledDate: null,
+                        dueDate: null,
+                    }),
+                )
+                .build();
+        });
+
+        return tasks;
+    }
+
+    public static withAllRootsPathsHeadings(): Task[] {
+        const allPathsAndHeadings: [string, string | null][] = [
+            ['', 'heading'],
+
+            // no heading supplied
+            ['a/b/c.md', null],
+
+            // File and heading, nominal case
+            ['a/d/c.md', 'heading'],
+            ['e/d/c.md', 'heading'],
+
+            // If file name and heading are identical, avoid duplication ('c > c')
+            ['a/b/c.md', 'c'],
+
+            // If file name and heading are identical, avoid duplication, even if there are underscores in the file name
+            ['a_b_c.md', 'a_b_c'],
+
+            // Underscores in file name component are escaped
+            ['a/b/_c_.md', null],
+
+            // But underscores in the heading component are not
+            ['a/b/_c_.md', 'heading _italic text_'],
+        ];
+        const t = '- [ ] xyz';
+
+        const tasks = allPathsAndHeadings.map(([path, heading]) => {
+            return fromLine({ line: t, path: path, precedingHeader: heading });
+        });
+
+        return tasks;
+    }
+
+    public static withAllRepresentativeCreatedDates(): Task[] {
+        const tasks = representativeDates.map((date) => {
+            return new TaskBuilder().createdDate(date).build();
+        });
+
+        return tasks;
+    }
+
+    public static withAllRepresentativeDoneDates(): Task[] {
+        const tasks = representativeDates.map((date) => {
+            return new TaskBuilder().doneDate(date).build();
+        });
+
+        return tasks;
+    }
+
+    public static withAllRepresentativeDueDates(): Task[] {
+        const tasks = representativeDates.map((date) => {
+            return new TaskBuilder().dueDate(date).build();
+        });
+
+        return tasks;
+    }
+
+    public static withAllRepresentativeScheduledDates(): Task[] {
+        const tasks = representativeDates.map((date) => {
+            return new TaskBuilder().scheduledDate(date).build();
+        });
+
+        return tasks;
+    }
+
+    public static withAllRepresentativeStartDates(): Task[] {
+        const tasks = representativeDates.map((date) => {
+            return new TaskBuilder().startDate(date).build();
+        });
+
+        return tasks;
+    }
+
+    public static withAllStatuses(): Task[] {
+        const statuses = [
+            Status.makeCancelled(),
+            Status.makeDone(),
+            Status.makeEmpty(),
+            Status.makeInProgress(),
+            Status.makeTodo(),
+        ];
+
+        const tasks = statuses.map((status) => {
+            return new TaskBuilder().status(status).build();
+        });
+
+        return tasks;
+    }
+
+    public static withAllStatusTypes(): Task[] {
+        // Abbreviated names so that the markdown text is aligned
+        const todoTask = fromLine({ line: '- [ ] Todo' });
+        const inprTask = fromLine({ line: '- [/] In progress' });
+        const doneTask = fromLine({ line: '- [x] Done' });
+        const cancTask = fromLine({ line: '- [-] Cancelled' });
+        const unknTask = fromLine({ line: '- [%] Unknown' });
+        const non_Task = new TaskBuilder()
+            .statusValues('^', 'non-task', 'x', false, StatusType.NON_TASK)
+            .description('Non-task')
+            .build();
+        const emptTask = new TaskBuilder().status(Status.makeEmpty()).description('Empty task').build();
+
+        return [todoTask, inprTask, doneTask, cancTask, unknTask, non_Task, emptTask];
+    }
+
+    public static withAllPriorities(): Task[] {
+        const tasks: Task[] = [];
+        const allPriorities = Object.values(Priority);
+        allPriorities.forEach((priority) => {
+            // This description is chosen to be useful for including tasks in user docs, so
+            // changing it will change documentation and sample vault content.
+            const priorityName = PriorityField.priorityNameUsingNormal(priority);
+            const description = `#task ${priorityName} priority`;
+
+            const task = new TaskBuilder().priority(priority).description(description).build();
+            tasks.push(task);
+        });
+        return tasks;
+    }
+}