Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2974 (2024-07-20 11:01:29)

fix: Prevent task.frontmatter.tags being null if no frontmatter
# Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Changes visible to users:

- [x] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue)
- [x] **Sample vault** (prefix: `vault` - improvements to the [Tasks-Demo sample vault](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/resources/sample_vaults/Tasks-Demo))

Internal changes:

- [x] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)

## Description

Fix:

- `TasksFile` now ensures that `task.frontmatter.tags` always exists, so 'no frontmatter' behaves the same as 'has frontmatter but no tags'

Tests:

- Make it easy to write tests of the behaviour with all the sample CachedMetadata saved data:
    - Add new file `tests/Obsidian/AllCacheSampleData.ts`, which exports a function `allCacheSampleData()`
    - Then make `resources/sample_vaults/Tasks-Demo/_meta/templater_scripts/convert_test_data_markdown_to_js.js` to also update the above file when new sample files are added
- Add systematic tests to `tests/Obsidian/Cache.test.ts`, showing the invariants of the code reading CachedMetadata, including the frontmatter
    - This will be useful when writing documentation
    - I am not super happy with the test names

Test vault:

- Update some searches to show simplified code
- Show how to group tasks by all tags, but ignore the global filter

## Motivation and Context

More progress on #2480

## How has this been tested?

- Adding new tests
- Exploratory testing in demo vault

## 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`.
- [x] 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 follows 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/resources/sample_vaults/Tasks-Demo/How To/Access properties in frontmatter.md b/resources/sample_vaults/Tasks-Demo/How To/Access properties in frontmatter.md
index ba06a298..940c3749 100644
--- a/resources/sample_vaults/Tasks-Demo/How To/Access properties in frontmatter.md
+++ b/resources/sample_vaults/Tasks-Demo/How To/Access properties in frontmatter.md
@@ -1,8 +1,5 @@
 # Access properties in frontmatter
 
-> [!warning]
-> Tasks does not currently sanitise tags from frontmatter, such as adding a  `#` prefix.
->
 ## Accessing a custom property
 
 ```tasks
@@ -14,5 +11,5 @@ group by function task.file.frontmatter.custom_number_prop ?? 'not set'
 
 ```tasks
 folder includes Test Data
-group by function task.file.frontmatter.tags ?? ''
+group by function task.file.frontmatter.tags
 ```
diff --git a/resources/sample_vaults/Tasks-Demo/How To/Find tasks in notes with particular tag.md b/resources/sample_vaults/Tasks-Demo/How To/Find tasks in notes with particular tag.md
index 8334b8dd..c918098c 100644
--- a/resources/sample_vaults/Tasks-Demo/How To/Find tasks in notes with particular tag.md
+++ b/resources/sample_vaults/Tasks-Demo/How To/Find tasks in notes with particular tag.md
@@ -36,7 +36,7 @@ Credit: jonlemon in [this Obsidian Forum thread](https://forum.obsidian.md/t/how
 #### Presence of tag - in frontmatter
 
 ```tasks
-filter by function task.file.frontmatter.tags?.includes('#examples') ?? false
+filter by function task.file.frontmatter.tags.includes('#examples')
 ```
 
 ### Tasks in files that have a Tag anywhere - in frontmatter or body
@@ -54,3 +54,24 @@ filter by function ! task.file.tags.includes('#examples')
 
 limit 20
 ```
+
+#### Group by tags - in frontmatter or body
+
+```tasks
+group by function task.file.tags
+folder does not include Test Data
+
+limit groups 3
+limit 100
+```
+
+#### Group by tags - in frontmatter or body, ignoring the global filter
+
+```tasks
+# TODO Provide task.file.tagsWithoutGlobalFilter
+group by function task.file.tags.filter( (t) => t !== '#task' )
+folder does not include Test Data
+
+limit groups 3
+limit 100
+```
diff --git a/src/Scripting/TasksFile.ts b/src/Scripting/TasksFile.ts
index 56fa677b..fbd98118 100644
--- a/src/Scripting/TasksFile.ts
+++ b/src/Scripting/TasksFile.ts
@@ -8,7 +8,8 @@ export type OptionalTasksFile = TasksFile | undefined;
 export class TasksFile {
     private readonly _path: string;
     private readonly _cachedMetadata: CachedMetadata;
-    private readonly _frontmatter: FrontMatterCache = {} as FrontMatterCache;
+    // Always make TasksFile.frontmatter.tags exist and be empty, even if no frontmatter present:
+    private readonly _frontmatter = { tags: [] } as any;
 
     constructor(path: string, cachedMetadata: CachedMetadata = {}) {
         this._path = path;


Test patch:
diff --git a/resources/sample_vaults/Tasks-Demo/_meta/templater_scripts/convert_test_data_markdown_to_js.js b/resources/sample_vaults/Tasks-Demo/_meta/templater_scripts/convert_test_data_markdown_to_js.js
index 4dd1148c..e3a88d86 100644
--- a/resources/sample_vaults/Tasks-Demo/_meta/templater_scripts/convert_test_data_markdown_to_js.js
+++ b/resources/sample_vaults/Tasks-Demo/_meta/templater_scripts/convert_test_data_markdown_to_js.js
@@ -1,59 +1,99 @@
 const fs = require('node:fs');
 const util = require('util');
 
+const vault = app.vault;
+
+async function getMarkdownFiles() {
+    // Get all files from Test Data/ directory
+    const { files } = await vault.adapter.list('Test Data/');
+    return files.filter((file) => file.endsWith('.md'));
+}
+
+function getBasename(filePath) {
+    return filePath.split('/')[1].replace('.md', '');
+}
+
+function getOutputFilePath(outputFile) {
+    const rootOfVault = vault.adapter.getBasePath();
+    return rootOfVault + '/../../../tests/Obsidian/' + outputFile;
+}
+
+function writeFile(testSourceFile, content) {
+    fs.writeFile(testSourceFile, content, (err) => {
+        if (err) {
+            console.error(err);
+        } else {
+            // file written successfully
+        }
+    });
+}
+
+function showNotice(message) {
+    new Notice(message);
+}
+
 async function convertMarkdownFileToTestFunction(filePath, tp) {
-    const tFile = app.vault.getAbstractFileByPath(filePath);
+    const tFile = vault.getAbstractFileByPath(filePath);
 
-    const fileContents = await app.vault.read(tFile);
+    const fileContents = await vault.read(tFile);
     const cachedMetadata = app.metadataCache.getFileCache(tFile);
     const obsidianApiVersion = tp.obsidian.apiVersion;
     const getAllTags = tp.obsidian.getAllTags(cachedMetadata);
     const parseFrontMatterTags = tp.obsidian.parseFrontMatterTags(cachedMetadata.frontmatter);
     const data = { filePath, fileContents, cachedMetadata, obsidianApiVersion, getAllTags, parseFrontMatterTags };
 
-    const filename = filePath.split('/')[1].replace('.md', '');
+    const filename = getBasename(filePath);
     if (filename.includes(' ')) {
         // The file name is used to create a TypeScript variable, so disallow spaces:
-        const message = `ERROR - spaces not allowed in filenames: "${filename}"`;
-        new Notice(message);
+        showNotice(`ERROR - spaces not allowed in filenames: "${filename}"`);
         return '';
     }
 
     if (!fileContents.endsWith('\n')) {
-        const message = `ERROR - missing newline character at end of: "${filename}"`;
-        new Notice(message);
+        showNotice(`ERROR - missing newline character at end of: "${filename}"`);
         return '';
     }
 
-    const rootOfVault = app.vault.adapter.getBasePath();
-    const testSourceFile = rootOfVault + '/../../../tests/Obsidian/__test_data__/' + filename + '.ts';
+    const testSourceFile = getOutputFilePath('__test_data__/' + filename + '.ts');
 
     const options = { depth: null, compact: false };
     const dataAsJSSource = util.inspect(data, options);
     const content = `export const ${filename} = ${dataAsJSSource};`;
+    writeFile(testSourceFile, content);
+}
 
-    fs.writeFile(testSourceFile, content, (err) => {
-        if (err) {
-            console.error(err);
-        } else {
-            // file written successfully
-        }
-    });
+async function writeListOfAllTestFunctions(files) {
+    const basenames = files.map((file) => getBasename(file));
+
+    const imports = basenames.map((filename) => `import { ${filename} } from './__test_data__/${filename}';`);
+    const functions = basenames.map((filename) => `        ${filename},`);
+
+    const content = `// DO NOT EDIT!
+// This file is machine-generated in the test vault, by convert_test_data_markdown_to_js.js.
+
+${imports.join('\n')}
+
+export function allCacheSampleData() {
+    return [
+${functions.join('\n')}
+    ];
+}
+`;
+
+    const testSourceFile = getOutputFilePath('AllCacheSampleData.ts');
+    writeFile(testSourceFile, content);
 }
 
 async function export_files(tp) {
-    // Get all files from Test Data/ directory
-    const { files } = await app.vault.adapter.list('Test Data/');
-    for (let i = 0; i < files.length; i++) {
-        const file = files[i];
-        if (!file.endsWith('.md')) {
-            continue;
-        }
+    const markdownFiles = await getMarkdownFiles();
+
+    for (const file of markdownFiles) {
         await convertMarkdownFileToTestFunction(file, tp);
     }
 
-    const message = 'Success - now run "yarn lint:test-data" to format the generated files.';
-    new Notice(message);
+    await writeListOfAllTestFunctions(markdownFiles);
+
+    showNotice('Success - now run "yarn lint:test-data" to format the generated files.');
     return '';
 }
 
diff --git a/tests/Obsidian/AllCacheSampleData.ts b/tests/Obsidian/AllCacheSampleData.ts
new file mode 100644
index 00000000..c1155a17
--- /dev/null
+++ b/tests/Obsidian/AllCacheSampleData.ts
@@ -0,0 +1,114 @@
+// DO NOT EDIT!
+// This file is machine-generated in the test vault, by convert_test_data_markdown_to_js.js.
+
+import { blockquote } from './__test_data__/blockquote';
+import { callout } from './__test_data__/callout';
+import { callout_custom } from './__test_data__/callout_custom';
+import { callout_labelled } from './__test_data__/callout_labelled';
+import { callouts_nested_issue_2890_labelled } from './__test_data__/callouts_nested_issue_2890_labelled';
+import { callouts_nested_issue_2890_unlabelled } from './__test_data__/callouts_nested_issue_2890_unlabelled';
+import { comments_html_style } from './__test_data__/comments_html_style';
+import { comments_markdown_style } from './__test_data__/comments_markdown_style';
+import { empty_yaml } from './__test_data__/empty_yaml';
+import { example_kanban } from './__test_data__/example_kanban';
+import { inheritance_1parent1child } from './__test_data__/inheritance_1parent1child';
+import { inheritance_1parent1child1newroot_after_header } from './__test_data__/inheritance_1parent1child1newroot_after_header';
+import { inheritance_1parent1child1sibling_emptystring } from './__test_data__/inheritance_1parent1child1sibling_emptystring';
+import { inheritance_1parent2children } from './__test_data__/inheritance_1parent2children';
+import { inheritance_1parent2children1grandchild } from './__test_data__/inheritance_1parent2children1grandchild';
+import { inheritance_1parent2children1sibling } from './__test_data__/inheritance_1parent2children1sibling';
+import { inheritance_1parent2children2grandchildren } from './__test_data__/inheritance_1parent2children2grandchildren';
+import { inheritance_1parent2children2grandchildren1sibling } from './__test_data__/inheritance_1parent2children2grandchildren1sibling';
+import { inheritance_1parent2children2grandchildren1sibling_start_with_heading } from './__test_data__/inheritance_1parent2children2grandchildren1sibling_start_with_heading';
+import { inheritance_2siblings } from './__test_data__/inheritance_2siblings';
+import { inheritance_listitem_task } from './__test_data__/inheritance_listitem_task';
+import { inheritance_listitem_task_siblings } from './__test_data__/inheritance_listitem_task_siblings';
+import { inheritance_task_2listitem_3task } from './__test_data__/inheritance_task_2listitem_3task';
+import { inheritance_task_listitem } from './__test_data__/inheritance_task_listitem';
+import { inheritance_task_listitem_mixed_grandchildren } from './__test_data__/inheritance_task_listitem_mixed_grandchildren';
+import { inheritance_task_listitem_task } from './__test_data__/inheritance_task_listitem_task';
+import { inheritance_task_mixed_children } from './__test_data__/inheritance_task_mixed_children';
+import { link_in_file_body } from './__test_data__/link_in_file_body';
+import { link_in_file_body_with_custom_display_text } from './__test_data__/link_in_file_body_with_custom_display_text';
+import { link_in_heading } from './__test_data__/link_in_heading';
+import { link_in_yaml } from './__test_data__/link_in_yaml';
+import { link_is_broken } from './__test_data__/link_is_broken';
+import { list_statuses } from './__test_data__/list_statuses';
+import { list_styles } from './__test_data__/list_styles';
+import { multi_line_task_and_list_item } from './__test_data__/multi_line_task_and_list_item';
+import { multiple_headings } from './__test_data__/multiple_headings';
+import { no_heading } from './__test_data__/no_heading';
+import { no_yaml } from './__test_data__/no_yaml';
+import { one_task } from './__test_data__/one_task';
+import { yaml_1_alias } from './__test_data__/yaml_1_alias';
+import { yaml_2_aliases } from './__test_data__/yaml_2_aliases';
+import { yaml_complex_example } from './__test_data__/yaml_complex_example';
+import { yaml_complex_example_standardised } from './__test_data__/yaml_complex_example_standardised';
+import { yaml_custom_number_property } from './__test_data__/yaml_custom_number_property';
+import { yaml_tags_field_added_by_obsidian_but_not_populated } from './__test_data__/yaml_tags_field_added_by_obsidian_but_not_populated';
+import { yaml_tags_had_value_then_was_emptied_by_obsidian } from './__test_data__/yaml_tags_had_value_then_was_emptied_by_obsidian';
+import { yaml_tags_has_multiple_values } from './__test_data__/yaml_tags_has_multiple_values';
+import { yaml_tags_is_empty } from './__test_data__/yaml_tags_is_empty';
+import { yaml_tags_is_empty_list } from './__test_data__/yaml_tags_is_empty_list';
+import { yaml_tags_with_one_value_on_new_line } from './__test_data__/yaml_tags_with_one_value_on_new_line';
+import { yaml_tags_with_one_value_on_single_line } from './__test_data__/yaml_tags_with_one_value_on_single_line';
+import { yaml_tags_with_two_values_on_one_line } from './__test_data__/yaml_tags_with_two_values_on_one_line';
+import { yaml_tags_with_two_values_on_two_lines } from './__test_data__/yaml_tags_with_two_values_on_two_lines';
+
+export function allCacheSampleData() {
+    return [
+        blockquote,
+        callout,
+        callout_custom,
+        callout_labelled,
+        callouts_nested_issue_2890_labelled,
+        callouts_nested_issue_2890_unlabelled,
+        comments_html_style,
+        comments_markdown_style,
+        empty_yaml,
+        example_kanban,
+        inheritance_1parent1child,
+        inheritance_1parent1child1newroot_after_header,
+        inheritance_1parent1child1sibling_emptystring,
+        inheritance_1parent2children,
+        inheritance_1parent2children1grandchild,
+        inheritance_1parent2children1sibling,
+        inheritance_1parent2children2grandchildren,
+        inheritance_1parent2children2grandchildren1sibling,
+        inheritance_1parent2children2grandchildren1sibling_start_with_heading,
+        inheritance_2siblings,
+        inheritance_listitem_task,
+        inheritance_listitem_task_siblings,
+        inheritance_task_2listitem_3task,
+        inheritance_task_listitem,
+        inheritance_task_listitem_mixed_grandchildren,
+        inheritance_task_listitem_task,
+        inheritance_task_mixed_children,
+        link_in_file_body,
+        link_in_file_body_with_custom_display_text,
+        link_in_heading,
+        link_in_yaml,
+        link_is_broken,
+        list_statuses,
+        list_styles,
+        multi_line_task_and_list_item,
+        multiple_headings,
+        no_heading,
+        no_yaml,
+        one_task,
+        yaml_1_alias,
+        yaml_2_aliases,
+        yaml_complex_example,
+        yaml_complex_example_standardised,
+        yaml_custom_number_property,
+        yaml_tags_field_added_by_obsidian_but_not_populated,
+        yaml_tags_had_value_then_was_emptied_by_obsidian,
+        yaml_tags_has_multiple_values,
+        yaml_tags_is_empty,
+        yaml_tags_is_empty_list,
+        yaml_tags_with_one_value_on_new_line,
+        yaml_tags_with_one_value_on_single_line,
+        yaml_tags_with_two_values_on_one_line,
+        yaml_tags_with_two_values_on_two_lines,
+    ];
+}
diff --git a/tests/Obsidian/Cache.test.ts b/tests/Obsidian/Cache.test.ts
index 9bf8328a..72317ddf 100644
--- a/tests/Obsidian/Cache.test.ts
+++ b/tests/Obsidian/Cache.test.ts
@@ -6,6 +6,8 @@ import type { CachedMetadata } from 'obsidian';
 import { logging } from '../../src/lib/logging';
 import { getTasksFromFileContent2 } from '../../src/Obsidian/Cache';
 import type { ListItem } from '../../src/Task/ListItem';
+import { setCurrentCacheFile } from '../__mocks__/obsidian';
+import { getTasksFileFromMockData, listPathAndData } from '../TestingTools/MockDataHelpers';
 import { inheritance_1parent1child } from './__test_data__/inheritance_1parent1child';
 import { inheritance_1parent1child1newroot_after_header } from './__test_data__/inheritance_1parent1child1newroot_after_header';
 import { inheritance_1parent1child1sibling_emptystring } from './__test_data__/inheritance_1parent1child1sibling_emptystring';
@@ -29,6 +31,7 @@ import { callout } from './__test_data__/callout';
 import { callout_labelled } from './__test_data__/callout_labelled';
 import { callout_custom } from './__test_data__/callout_custom';
 import { callouts_nested_issue_2890_unlabelled } from './__test_data__/callouts_nested_issue_2890_unlabelled';
+import { allCacheSampleData } from './AllCacheSampleData';
 
 window.moment = moment;
 
@@ -75,6 +78,7 @@ interface SimulatedFile {
 
 function readTasksFromSimulatedFile(testData: SimulatedFile) {
     const logger = logging.getLogger('testCache');
+    setCurrentCacheFile(testData);
     return getTasksFromFileContent2(
         testData.filePath,
         testData.fileContents,
@@ -605,3 +609,33 @@ describe('cache', () => {
         expect(tasks.length).toEqual(4);
     });
 });
+
+describe('all mock files', () => {
+    const files: any = allCacheSampleData();
+
+    it.each(listPathAndData(files))(
+        'should create valid TasksFile for all mock files: "%s"',
+        (_path: string, file: any) => {
+            const tasksFile = getTasksFileFromMockData(file);
+
+            const frontmatter = tasksFile.frontmatter;
+            expect(frontmatter).not.toBeUndefined();
+            expect(frontmatter).not.toBeNull();
+
+            // We always define frontmatter.tags, even if there was no frontmatter,
+            // to simplify a common user operation in custom filters.
+            expect(frontmatter.tags).not.toBeUndefined();
+            expect(frontmatter.tags).not.toBeNull();
+            expect(frontmatter.tags).not.toContain(null);
+            expect(frontmatter.tags).not.toContain(undefined);
+        },
+    );
+
+    it.each(listPathAndData(files))(
+        'should be able to read tasks from all mock files: "%s"',
+        (_path: string, file: any) => {
+            const tasks = readTasksFromSimulatedFile(file);
+            expect(tasks.length).toBeGreaterThan(0);
+        },
+    );
+});
diff --git a/tests/Scripting/TasksFile.test.ts b/tests/Scripting/TasksFile.test.ts
index 2f6acde4..7d081c6e 100644
--- a/tests/Scripting/TasksFile.test.ts
+++ b/tests/Scripting/TasksFile.test.ts
@@ -1,6 +1,4 @@
-import type { CachedMetadata } from 'obsidian';
 import { TasksFile } from '../../src/Scripting/TasksFile';
-import { setCurrentCacheFile } from '../__mocks__/obsidian';
 import { callouts_nested_issue_2890_unlabelled } from '../Obsidian/__test_data__/callouts_nested_issue_2890_unlabelled';
 import { no_yaml } from '../Obsidian/__test_data__/no_yaml';
 import { empty_yaml } from '../Obsidian/__test_data__/empty_yaml';
@@ -12,6 +10,7 @@ import { yaml_tags_had_value_then_was_emptied_by_obsidian } from '../Obsidian/__
 import { yaml_tags_is_empty_list } from '../Obsidian/__test_data__/yaml_tags_is_empty_list';
 import { yaml_tags_is_empty } from '../Obsidian/__test_data__/yaml_tags_is_empty';
 import { example_kanban } from '../Obsidian/__test_data__/example_kanban';
+import { getTasksFileFromMockData, listPathAndData } from '../TestingTools/MockDataHelpers';
 
 describe('TasksFile', () => {
     it('should provide access to path', () => {
@@ -60,37 +59,26 @@ describe('TasksFile', () => {
     });
 });
 
-function getTasksFileFromMockData(data: any) {
-    setCurrentCacheFile(data);
-    const cachedMetadata = data.cachedMetadata as any as CachedMetadata;
-    return new TasksFile(data.filePath, cachedMetadata);
-}
-
-function listPathAndData(inputs: any[]) {
-    // We use map() to extract the path, to use it as a test name in it.each()
-    return inputs.map((data) => [data.filePath, data]);
-}
-
 describe('TasksFile - reading frontmatter', () => {
     it('should read file if not given CachedMetadata', () => {
         const tasksFile = new TasksFile('some path.md', {});
 
         expect(tasksFile.cachedMetadata.frontmatter).toBeUndefined();
-        expect(tasksFile.frontmatter).toEqual({});
+        expect(tasksFile.frontmatter).toEqual({ tags: [] });
     });
 
     it('should read file with no yaml metadata', () => {
         const tasksFile = getTasksFileFromMockData(no_yaml);
         expect(tasksFile.cachedMetadata.frontmatter).toBeUndefined();
-        expect(tasksFile.frontmatter).toEqual({});
-        expect(tasksFile.frontmatter.tags).toEqual(undefined); // TODO This will be inconvenient for users - should be []?
+        expect(tasksFile.frontmatter).toEqual({ tags: [] });
+        expect(tasksFile.frontmatter.tags).toEqual([]);
     });
 
     it('should read file with empty yaml metadata', () => {
         const tasksFile = getTasksFileFromMockData(empty_yaml);
         expect(tasksFile.cachedMetadata.frontmatter).toBeUndefined();
-        expect(tasksFile.frontmatter).toEqual({});
-        expect(tasksFile.frontmatter.tags).toEqual(undefined); // TODO This will be inconvenient for users - should be []?
+        expect(tasksFile.frontmatter).toEqual({ tags: [] });
+        expect(tasksFile.frontmatter.tags).toEqual([]);
     });
 
     it('should provide an independent copy of frontmatter', () => {
diff --git a/tests/TestingTools/MockDataHelpers.ts b/tests/TestingTools/MockDataHelpers.ts
new file mode 100644
index 00000000..4b77c13d
--- /dev/null
+++ b/tests/TestingTools/MockDataHelpers.ts
@@ -0,0 +1,16 @@
+import type { CachedMetadata } from 'obsidian';
+import { setCurrentCacheFile } from '../__mocks__/obsidian';
+import { TasksFile } from '../../src/Scripting/TasksFile';
+
+// For explanation of the mock data, see Cache.test.ts.
+
+export function getTasksFileFromMockData(data: any) {
+    setCurrentCacheFile(data);
+    const cachedMetadata = data.cachedMetadata as CachedMetadata;
+    return new TasksFile(data.filePath, cachedMetadata);
+}
+
+export function listPathAndData(inputs: any[]) {
+    // We use map() to extract the path, to use it as a test name in it.each()
+    return inputs.map((data) => [data.filePath, data]);
+}
diff --git a/tests/ui/EditableTask.test.ts b/tests/ui/EditableTask.test.ts
index cddc6182..4c148b72 100644
--- a/tests/ui/EditableTask.test.ts
+++ b/tests/ui/EditableTask.test.ts
@@ -195,7 +195,9 @@ describe('EditableTask tests', () => {
                 "_sectionStart": 5,
                 "_tasksFile": TasksFile {
                   "_cachedMetadata": {},
-                  "_frontmatter": {},
+                  "_frontmatter": {
+                    "tags": [],
+                  },
                   "_path": "some/folder/fileName.md",
                 },
               },