Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2837 (2024-05-19 16:31:19)

test: test `Cache.getTasksFromFileContent()`
# Description

Done by pairing with @claremacrae 

- add way to test `Cache.getTasksFromFileContent()`
- add framework to extract data from Obsidian in oder to create test data

## Motivation and Context

- prepare for parent-child relationship tests

## How has this been tested?

- new unit tests

## Types of changes

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)

## 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

<!--
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/package.json b/package.json
index acfd4a53..6c322546 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
     "build": "node esbuild.config.mjs production",
     "build:dev": "node esbuild.config.mjs development",
     "lint": "eslint ./src --fix && eslint ./tests --fix && tsc --noEmit --pretty && svelte-check",
-    "lint:markdown": "markdownlint-cli2-fix \"**/*.md\" \"#contributing/_meta\" \"#docs/_meta\" \"#docs-snippets\" \"#node_modules\"  \"#tests\"  \"#resources/sample_vaults/Tasks-Demo/_meta/templates\"",
+    "lint:markdown": "markdownlint-cli2-fix \"**/*.md\" \"#contributing/_meta\" \"#docs/_meta\" \"#docs-snippets\" \"#node_modules\"  \"#tests\"  \"#resources/sample_vaults/Tasks-Demo/_meta/templates\"  \"#resources/sample_vaults/Tasks-Demo/Test Data\"",
     "test": "jest --ci",
     "test:dev": "jest --watch",
     "deploy:local": "pwsh -ExecutionPolicy Unrestricted -NoProfile -File ./scripts/Test-TasksInLocalObsidian.ps1",
diff --git a/src/Obsidian/Cache.ts b/src/Obsidian/Cache.ts
index 1e3ffc0b..638fee58 100644
--- a/src/Obsidian/Cache.ts
+++ b/src/Obsidian/Cache.ts
@@ -9,7 +9,7 @@ import { DateFallback } from '../Task/DateFallback';
 import { getSettings } from '../Config/Settings';
 import { Lazy } from '../lib/Lazy';
 import { TaskLocation } from '../Task/TaskLocation';
-import { logging } from '../lib/logging';
+import { Logger, logging } from '../lib/logging';
 import type { TasksEvents } from './TasksEvents';
 
 export enum State {
@@ -18,6 +18,94 @@ export enum State {
     Warm = 'Warm',
 }
 
+export function getTasksFromFileContent2(
+    filePath: string,
+    fileContent: string,
+    listItems: ListItemCache[],
+    logger: Logger,
+    fileCache: CachedMetadata,
+    errorReporter: (e: any, filePath: string, listItem: ListItemCache, line: string) => void,
+) {
+    const tasksFile = new TasksFile(filePath);
+    const tasks: Task[] = [];
+    const fileLines = fileContent.split('\n');
+    const linesInFile = fileLines.length;
+
+    // Lazily store date extracted from filename to avoid parsing more than needed
+    // this.logger.debug(`getTasksFromFileContent() reading ${file.path}`);
+    const dateFromFileName = new Lazy(() => DateFallback.fromPath(filePath));
+
+    // We want to store section information with every task so
+    // that we can use that when we post process the markdown
+    // rendered lists.
+    let currentSection: SectionCache | null = null;
+    let sectionIndex = 0;
+    for (const listItem of listItems) {
+        if (listItem.task !== undefined) {
+            const lineNumber = listItem.position.start.line;
+            if (lineNumber >= linesInFile) {
+                /*
+                    Obsidian CachedMetadata has told us that there is a task on lineNumber, but there are
+                    not that many lines in the file.
+
+                    This was the underlying cause of all the 'Stuck on "Loading Tasks..."' messages,
+                    as it resulted in the line 'undefined' being parsed.
+
+                    Somehow the file had been shortened whilst Obsidian was closed, meaning that
+                    when Obsidian started up, it got the new file content, but still had the old cached
+                    data about locations of list items in the file.
+                 */
+                logger.debug(
+                    `${filePath} Obsidian gave us a line number ${lineNumber} past the end of the file. ${linesInFile}.`,
+                );
+                return tasks;
+            }
+            if (currentSection === null || currentSection.position.end.line < lineNumber) {
+                // We went past the current section (or this is the first task).
+                // Find the section that is relevant for this task and the following of the same section.
+                currentSection = Cache.getSection(lineNumber, fileCache.sections);
+                sectionIndex = 0;
+            }
+
+            if (currentSection === null) {
+                // Cannot process a task without a section.
+                continue;
+            }
+
+            const line = fileLines[lineNumber];
+            if (line === undefined) {
+                logger.debug(`${filePath}: line ${lineNumber} - ignoring 'undefined' line.`);
+                continue;
+            }
+
+            let task;
+            try {
+                task = Task.fromLine({
+                    line,
+                    taskLocation: new TaskLocation(
+                        tasksFile,
+                        lineNumber,
+                        currentSection.position.start.line,
+                        sectionIndex,
+                        Cache.getPrecedingHeader(lineNumber, fileCache.headings),
+                    ),
+                    fallbackDate: dateFromFileName.value,
+                });
+            } catch (e) {
+                errorReporter(e, filePath, listItem, line);
+                continue;
+            }
+
+            if (task !== null) {
+                sectionIndex++;
+                tasks.push(task);
+            }
+        }
+    }
+
+    return tasks;
+}
+
 export class Cache {
     logger = logging.getLogger('tasks.Cache');
 
@@ -236,7 +324,14 @@ export class Cache {
         if (listItems !== undefined) {
             // Only read the file and process for tasks if there are list items.
             const fileContent = await this.vault.cachedRead(file);
-            newTasks = this.getTasksFromFileContent(fileContent, listItems, fileCache, file);
+            newTasks = this.getTasksFromFileContent(
+                fileContent,
+                listItems,
+                fileCache,
+                file.path,
+                this.reportTaskParsingErrorToUser,
+                this.logger,
+            );
         }
 
         // If there are no changes in any of the tasks, there's
@@ -277,93 +372,18 @@ export class Cache {
         fileContent: string,
         listItems: ListItemCache[],
         fileCache: CachedMetadata,
-        file: TFile,
+        filePath: string,
+        errorReporter: (e: any, filePath: string, listItem: ListItemCache, line: string) => void,
+        logger: Logger,
     ): Task[] {
-        const tasksFile = new TasksFile(file.path);
-        const tasks: Task[] = [];
-        const fileLines = fileContent.split('\n');
-        const linesInFile = fileLines.length;
-
-        // Lazily store date extracted from filename to avoid parsing more than needed
-        // this.logger.debug(`getTasksFromFileContent() reading ${file.path}`);
-        const dateFromFileName = new Lazy(() => DateFallback.fromPath(file.path));
-
-        // We want to store section information with every task so
-        // that we can use that when we post process the markdown
-        // rendered lists.
-        let currentSection: SectionCache | null = null;
-        let sectionIndex = 0;
-        for (const listItem of listItems) {
-            if (listItem.task !== undefined) {
-                const lineNumber = listItem.position.start.line;
-                if (lineNumber >= linesInFile) {
-                    /*
-                        Obsidian CachedMetadata has told us that there is a task on lineNumber, but there are
-                        not that many lines in the file.
-
-                        This was the underlying cause of all the 'Stuck on "Loading Tasks..."' messages,
-                        as it resulted in the line 'undefined' being parsed.
-
-                        Somehow the file had been shortened whilst Obsidian was closed, meaning that
-                        when Obsidian started up, it got the new file content, but still had the old cached
-                        data about locations of list items in the file.
-                     */
-                    this.logger.debug(
-                        `${file.path} Obsidian gave us a line number ${lineNumber} past the end of the file. ${linesInFile}.`,
-                    );
-                    return tasks;
-                }
-                if (currentSection === null || currentSection.position.end.line < lineNumber) {
-                    // We went past the current section (or this is the first task).
-                    // Find the section that is relevant for this task and the following of the same section.
-                    currentSection = Cache.getSection(lineNumber, fileCache.sections);
-                    sectionIndex = 0;
-                }
-
-                if (currentSection === null) {
-                    // Cannot process a task without a section.
-                    continue;
-                }
-
-                const line = fileLines[lineNumber];
-                if (line === undefined) {
-                    this.logger.debug(`${file.path}: line ${lineNumber} - ignoring 'undefined' line.`);
-                    continue;
-                }
-
-                let task;
-                try {
-                    task = Task.fromLine({
-                        line,
-                        taskLocation: new TaskLocation(
-                            tasksFile,
-                            lineNumber,
-                            currentSection.position.start.line,
-                            sectionIndex,
-                            Cache.getPrecedingHeader(lineNumber, fileCache.headings),
-                        ),
-                        fallbackDate: dateFromFileName.value,
-                    });
-                } catch (e) {
-                    this.reportTaskParsingErrorToUser(e, file, listItem, line);
-                    continue;
-                }
-
-                if (task !== null) {
-                    sectionIndex++;
-                    tasks.push(task);
-                }
-            }
-        }
-
-        return tasks;
+        return getTasksFromFileContent2(filePath, fileContent, listItems, logger, fileCache, errorReporter);
     }
 
-    private reportTaskParsingErrorToUser(e: any, file: TFile, listItem: ListItemCache, line: string) {
+    private reportTaskParsingErrorToUser(e: any, filePath: string, listItem: ListItemCache, line: string) {
         const msg = `There was an error reading one of the tasks in this vault.
 The following task has been ignored, to prevent Tasks queries getting stuck with 'Loading Tasks ...'
 Error: ${e}
-File: ${file.path}
+File: ${filePath}
 Line number: ${listItem.position.start.line}
 Task line: ${line}
 
@@ -388,7 +408,7 @@ session.
         }
     }
 
-    private static getSection(lineNumberTask: number, sections: SectionCache[] | undefined): SectionCache | null {
+    public static getSection(lineNumberTask: number, sections: SectionCache[] | undefined): SectionCache | null {
         if (sections === undefined) {
             return null;
         }
@@ -402,7 +422,7 @@ session.
         return null;
     }
 
-    private static getPrecedingHeader(lineNumberTask: number, headings: HeadingCache[] | undefined): string | null {
+    public static getPrecedingHeader(lineNumberTask: number, headings: HeadingCache[] | undefined): string | null {
         if (headings === undefined) {
             return null;
         }


Test patch:
diff --git a/resources/sample_vaults/Tasks-Demo/Test Data/one_task.md b/resources/sample_vaults/Tasks-Demo/Test Data/one_task.md
new file mode 100644
index 00000000..d8367748
--- /dev/null
+++ b/resources/sample_vaults/Tasks-Demo/Test Data/one_task.md
@@ -0,0 +1,2 @@
+- [ ] #task the only task here
+
diff --git a/tests/Obsidian/Cache.test.ts b/tests/Obsidian/Cache.test.ts
new file mode 100644
index 00000000..447a9502
--- /dev/null
+++ b/tests/Obsidian/Cache.test.ts
@@ -0,0 +1,67 @@
+import type { CachedMetadata } from 'obsidian';
+import { logging } from '../../src/lib/logging';
+import { getTasksFromFileContent2 } from '../../src/Obsidian/Cache';
+import { one_task } from './__test_data__/one_task';
+
+function errorReporter() {
+    return;
+}
+
+/* Test creation sequence:
+
+- Create a sample markdown file in Tasks demo vault (root/Test Data/) with the simplest content
+to represent your test case. Choose a meaningful file name in snake case. See example in 'Test Data/one_task.md'.
+
+- Copy-paste the function below in the Obsidian developer console:
+
+async function getData(filePath) {
+    const tFile = app.vault.getAbstractFileByPath(filePath);
+
+    const fileContents = await app.vault.read(tFile);
+    const cachedMetadata = app.metadataCache.getFileCache(tFile);
+    console.log({ filePath, fileContents, cachedMetadata });
+}
+
+- Call it with your file path in the console:
+
+await getData('Test Data/one_task.md')
+
+- Right-click on the object in the console and select 'Copy object'.
+
+- Paste that data in a .ts file for your test as a constant with
+a meaningful name (See example in test/Obsidian/__test_data__/one_task.ts). Set file name same as
+the markdown file in the demo vault. The data will be in JSON format, so reformat the data as a destructured object
+with your IDE.
+
+- Use the data in the test with `readTasksFromSimulatedFile()`, the argument is the constant you
+created in the previous step.
+
+- Remember to commit the markdown file in the demo vault and the file with the simulated data.
+
+ */
+
+interface SimulatedFile {
+    cachedMetadata: CachedMetadata;
+    filePath: string;
+    fileContents: string;
+}
+
+function readTasksFromSimulatedFile(testData: SimulatedFile) {
+    const logger = logging.getLogger('testCache');
+    return getTasksFromFileContent2(
+        testData.filePath,
+        testData.fileContents,
+        testData.cachedMetadata.listItems!,
+        logger,
+        testData.cachedMetadata,
+        errorReporter,
+    );
+}
+
+describe('cache', () => {
+    it('should read one task', () => {
+        const tasks = readTasksFromSimulatedFile(one_task);
+        expect(tasks.length).toEqual(1);
+        expect(tasks[0].description).toEqual('#task the only task here');
+    });
+});
diff --git a/tests/Obsidian/__test_data__/one_task.ts b/tests/Obsidian/__test_data__/one_task.ts
new file mode 100644
index 00000000..7d88c04c
--- /dev/null
+++ b/tests/Obsidian/__test_data__/one_task.ts
@@ -0,0 +1,60 @@
+// test data from one_task.md
+
+export const one_task = {
+    filePath: 'Test Data/one_task.md',
+    fileContents: '- [ ] #task the only task here\n\n',
+    cachedMetadata: {
+        tags: [
+            {
+                position: {
+                    start: {
+                        line: 0,
+                        col: 6,
+                        offset: 6,
+                    },
+                    end: {
+                        line: 0,
+                        col: 11,
+                        offset: 11,
+                    },
+                },
+                tag: '#task',
+            },
+        ],
+        sections: [
+            {
+                type: 'list',
+                position: {
+                    start: {
+                        line: 0,
+                        col: 0,
+                        offset: 0,
+                    },
+                    end: {
+                        line: 0,
+                        col: 30,
+                        offset: 30,
+                    },
+                },
+            },
+        ],
+        listItems: [
+            {
+                position: {
+                    start: {
+                        line: 0,
+                        col: 0,
+                        offset: 0,
+                    },
+                    end: {
+                        line: 0,
+                        col: 30,
+                        offset: 30,
+                    },
+                },
+                parent: -1,
+                task: ' ',
+            },
+        ],
+    },
+};