Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1906 (2023-04-23 01:03:27)

test: Normalize timezone for tests to UTC
<!--- Provide a general summary of your changes in the Title above -->

# Description

This PR fixes an issue where the tests in `tests/Query/Filter/ReferenceDocs/FilterReference/DateFieldReference.test.ts` will fail if run in a timezone with a negative offset relative to UTC (i.e. if run on a machine in North America).

## Background

`DateFieldReference.test.ts` mocks the current date by setting it to `new Date('2023-04-19')` which corresponds to "'Wed, 19 Apr 2023 00:00:00 GMT'"

https://github.com/obsidian-tasks-group/obsidian-tasks/blob/88a519fa475b7ee672d3cfbf8aa64efe78b44a4b/tests/Query/Filter/ReferenceDocs/FilterReference/DateFieldReference.test.ts#L12-L15


The tests run in that file expect that the date is Wednesday April 19th 2023 as well 

https://github.com/obsidian-tasks-group/obsidian-tasks/blob/88a519fa475b7ee672d3cfbf8aa64efe78b44a4b/tests/Query/Filter/ReferenceDocs/FilterReference/DateFieldReference.test.explain_date_reference_last-this-next-weekday.approved.explanation.text#L1-L7

This is an issue when your timezone is at a negative offset relative to GMT. Tasks is timezone aware and will format "Wed, 19 Apr 2023 00:00:00 GMT'" as some instant in time on Tuesday the 18th of April.

## Motivation and Context

Needed to resolve a conflict for another PR but tests were failing

## How has this been tested?

The tests that were failing pass now

## Screenshots (if appropriate)

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Changes visible to users:

- [ ] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue)
- [ ] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
- [ ] **Breaking change** (prefix: `feat!!` or `fix!!` - fix or feature that would cause existing functionality to not work as expected)
- [ ] **Documentation** (prefix: `docs` - improvements to any documentation content **for users**)
- [ ] **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))
- [ ] **Contributing Guidelines** (prefix: `contrib` - any improvements to documentation content **for contributors** - see [Contributing to Tasks](https://publish.obsidian.md/tasks-contributing/))

Internal changes:

- [ ] **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)
- [ ] **Infrastructure** (prefix: `chore` - examples include GitHub Actions, issue templates)

## 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/Getting Started/Auto-Suggest.md b/docs/Getting Started/Auto-Suggest.md
index 2aa489c5..079e39e4 100644
--- a/docs/Getting Started/Auto-Suggest.md
+++ b/docs/Getting Started/Auto-Suggest.md
@@ -194,7 +194,7 @@ Similarly, you can type some fraction of the word `start` (of whatever length is
 | 📅 due date                   | 📅                         |
 | ⏳ scheduled date             | ⏳                         |
 | 🛫 start date                 | 🛫                         |
-| ➕ created today (2022-07-11) | ➕ 2022-07-11              |
+| ➕ created                    | ➕ 2022-07-11              |
 | today (2022-07-11)            | 2022-07-11                 |
 | tomorrow (2022-07-12)         | 2022-07-12                 |
 | Sunday (2022-07-17)           | 2022-07-17                 |
diff --git a/jest.config.js b/jest.config.js
index 5f4f6d81..f97891f0 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -16,4 +16,5 @@ module.exports = {
     // A list of paths to modules that run some code to configure or
     // set up the testing framework before each test.
     setupFilesAfterEnv: ['<rootDir>/tests/CustomMatchers/jest.custom_matchers.setup.ts'],
+    globalSetup: "./tests/global-setup.js"
 };
diff --git a/src/Suggestor/Suggestor.ts b/src/Suggestor/Suggestor.ts
index 8c91d70b..0d1139d4 100644
--- a/src/Suggestor/Suggestor.ts
+++ b/src/Suggestor/Suggestor.ts
@@ -20,36 +20,8 @@ export function makeDefaultSuggestionBuilder(symbols: DefaultTaskSerializerSymbo
         // Step 2: add recurrence suggestions if relevant
         suggestions = suggestions.concat(addRecurrenceSuggestions(line, cursorPos, settings, symbols.recurrenceSymbol));
 
-        // Step 3: add more general suggestions ('due', 'recurrence' etc)
-        const morePossibleSuggestions = getPossibleComponentSuggestions(line, settings, symbols);
-        // We now filter the general suggestions according to the word at the cursor. If there's
-        // something to match, we filter the suggestions accordingly, so the user can get more specific
-        // results according to what she's typing.
-        // If there's no good match, present the suggestions as they are
-        const wordMatch = matchByPosition(line, /([a-zA-Z'_-]*)/g, cursorPos);
-        let addedSuggestions = false;
-        if (wordMatch && wordMatch.length > 0) {
-            const wordUnderCursor = wordMatch[0];
-            if (wordUnderCursor.length >= Math.max(1, settings.autoSuggestMinMatch)) {
-                const filteredSuggestions = morePossibleSuggestions.filter((suggestInfo) =>
-                    suggestInfo.displayText.toLowerCase().includes(wordUnderCursor.toLowerCase()),
-                );
-                for (const filtered of filteredSuggestions) {
-                    suggestions.push({
-                        suggestionType: 'match',
-                        displayText: filtered.displayText,
-                        appendText: filtered.appendText,
-                        insertAt: wordMatch.index,
-                        insertSkip: wordUnderCursor.length,
-                    });
-                    addedSuggestions = true;
-                }
-            }
-        }
-        // That's where we're adding all the suggestions in case there's nothing specific to match
-        // (and we're allowed by the settings to bring back a zero-sized match)
-        if (!addedSuggestions && settings.autoSuggestMinMatch === 0)
-            suggestions = suggestions.concat(morePossibleSuggestions);
+        // Step 3: add task property suggestions ('due', 'recurrence' etc)
+        suggestions = suggestions.concat(addTaskPropertySuggestions(line, cursorPos, settings, symbols));
 
         // Unless we have a suggestion that is a match for something the user is currently typing, add
         // an 'Enter' entry in the beginning of the menu, so an Enter press will move to the next line
@@ -73,60 +45,91 @@ export function makeDefaultSuggestionBuilder(symbols: DefaultTaskSerializerSymbo
 /*
  * Get suggestions for generic task components, e.g. a priority or a 'due' symbol
  */
-function getPossibleComponentSuggestions(
+function addTaskPropertySuggestions(
     line: string,
+    cursorPos: number,
     _settings: Settings,
     symbols: DefaultTaskSerializerSymbols,
 ): SuggestInfo[] {
     const hasPriority = (line: string) =>
         Object.values(symbols.prioritySymbols).some((value) => value.length > 0 && line.includes(value));
 
-    const suggestions: SuggestInfo[] = [];
+    const genericSuggestions: SuggestInfo[] = [];
 
     if (!line.includes(symbols.dueDateSymbol))
-        suggestions.push({
+        genericSuggestions.push({
             displayText: `${symbols.dueDateSymbol} due date`,
             appendText: `${symbols.dueDateSymbol} `,
         });
     if (!line.includes(symbols.startDateSymbol))
-        suggestions.push({
+        genericSuggestions.push({
             displayText: `${symbols.startDateSymbol} start date`,
             appendText: `${symbols.startDateSymbol} `,
         });
     if (!line.includes(symbols.scheduledDateSymbol))
-        suggestions.push({
+        genericSuggestions.push({
             displayText: `${symbols.scheduledDateSymbol} scheduled date`,
             appendText: `${symbols.scheduledDateSymbol} `,
         });
     if (!hasPriority(line)) {
-        suggestions.push({
+        genericSuggestions.push({
             displayText: `${symbols.prioritySymbols.High} high priority`,
             appendText: `${symbols.prioritySymbols.High} `,
         });
-        suggestions.push({
+        genericSuggestions.push({
             displayText: `${symbols.prioritySymbols.Medium} medium priority`,
             appendText: `${symbols.prioritySymbols.Medium} `,
         });
-        suggestions.push({
+        genericSuggestions.push({
             displayText: `${symbols.prioritySymbols.Low} low priority`,
             appendText: `${symbols.prioritySymbols.Low} `,
         });
     }
     if (!line.includes(symbols.recurrenceSymbol))
-        suggestions.push({
+        genericSuggestions.push({
             displayText: `${symbols.recurrenceSymbol} recurring (repeat)`,
             appendText: `${symbols.recurrenceSymbol} `,
         });
     if (!line.includes(symbols.createdDateSymbol)) {
         const parsedDate = DateParser.parseDate('today', true);
         const formattedDate = parsedDate.format(TaskRegularExpressions.dateFormat);
-        suggestions.push({
+        genericSuggestions.push({
+            // We don't want this to match when the user types "today"
+            textToMatch: `${symbols.createdDateSymbol} created`,
             displayText: `${symbols.createdDateSymbol} created today (${formattedDate})`,
             appendText: `${symbols.createdDateSymbol} ${formattedDate} `,
         });
     }
 
-    return suggestions;
+    // We now filter the general suggestions according to the word at the cursor. If there's
+    // something to match, we filter the suggestions accordingly, so the user can get more specific
+    // results according to what she's typing.
+    // If there's no good match, present the suggestions as they are
+    const wordMatch = matchByPosition(line, /([a-zA-Z'_-]*)/g, cursorPos);
+    const matchingSuggestions: SuggestInfo[] = [];
+    if (wordMatch && wordMatch.length > 0) {
+        const wordUnderCursor = wordMatch[0];
+        if (wordUnderCursor.length >= Math.max(1, _settings.autoSuggestMinMatch)) {
+            const filteredSuggestions = genericSuggestions.filter((suggestInfo) => {
+                const textToMatch = suggestInfo.textToMatch || suggestInfo.displayText;
+                return textToMatch.toLowerCase().includes(wordUnderCursor.toLowerCase());
+            });
+            for (const filtered of filteredSuggestions) {
+                matchingSuggestions.push({
+                    suggestionType: 'match',
+                    displayText: filtered.displayText,
+                    appendText: filtered.appendText,
+                    insertAt: wordMatch.index,
+                    insertSkip: wordUnderCursor.length,
+                });
+            }
+        }
+    }
+    // That's where we're adding all the suggestions in case there's nothing specific to match
+    // (and we're allowed by the settings to bring back a zero-sized match)
+    if (matchingSuggestions.length === 0 && _settings.autoSuggestMinMatch === 0) return genericSuggestions;
+
+    return matchingSuggestions;
 }
 
 /*
@@ -317,10 +320,9 @@ function addRecurrenceSuggestions(line: string, cursorPos: number, settings: Set
  * Matches a string with a regex according to a position (typically of a cursor).
  * Will return a result only if a match exists and the given position is part of it.
  */
-export function matchByPosition(s: string, r: RegExp, position: number): RegExpMatchArray {
+export function matchByPosition(s: string, r: RegExp, position: number): RegExpMatchArray | void {
     const matches = s.matchAll(r);
     for (const match of matches) {
         if (match?.index && match.index <= position && position <= match.index + match[0].length) return match;
     }
-    return [];
 }
diff --git a/src/Suggestor/index.ts b/src/Suggestor/index.ts
index 1c135a1a..b2f198aa 100644
--- a/src/Suggestor/index.ts
+++ b/src/Suggestor/index.ts
@@ -5,14 +5,16 @@ import type { Settings } from '../Config/Settings';
  */
 export type SuggestInfo = {
     suggestionType?: 'match' | 'default' | 'empty';
-    // What to display to the user
+    /** What to display to the user  */
     displayText: string;
-    // What to append to the note
+    /** What to append to the note  */
     appendText: string;
-    // At what index in the line to do the insertion (if not specified, the cursor location is used)
+    /** At what index in the line to do the insertion (if not specified, the cursor location is used) */
     insertAt?: number;
-    // How many characters to skip from the original line (e.g. if replacing existing text)
+    /** How many characters to skip from the original line (e.g. if replacing existing text) */
     insertSkip?: number;
+    /** Text to match with the user input if matching against the display text is not desirable */
+    textToMatch?: string;
 };
 
 /*


Test patch:
diff --git a/tests/Suggestor/Suggestor.test.ts b/tests/Suggestor/Suggestor.test.ts
index 436f1aa5..da21dd3b 100644
--- a/tests/Suggestor/Suggestor.test.ts
+++ b/tests/Suggestor/Suggestor.test.ts
@@ -14,9 +14,13 @@ window.moment = moment;
 // Set predictable date for all tests in this file
 const mockDate = new Date(moment('2022-07-11 15:00').valueOf());
 
-jest.spyOn(chrono, 'parseDate').mockImplementation(
-    (text, _, options) => chrono.en.casual.parseDate(text, mockDate, options)!,
-);
+const chronoSpy = jest
+    .spyOn(chrono, 'parseDate')
+    .mockImplementation((text, _, options) => chrono.en.casual.parseDate(text, mockDate, options)!);
+
+afterAll(() => {
+    chronoSpy.mockRestore();
+});
 
 describe.each([
     { name: 'emoji', symbols: DEFAULT_SYMBOLS },
@@ -98,6 +102,19 @@ describe.each([
         expect(suggestions[1].displayText).toEqual('every day');
     });
 
+    it('matches created property suggestion when user types "created" but not "today"', () => {
+        // Arrange
+        const originalSettings = getSettings();
+        let line = '- [ ] some task cr';
+        let suggestions: SuggestInfo[] = buildSuggestions(line, 18, originalSettings);
+        expect(suggestions[0].displayText).toEqual(`${createdDateSymbol} created today (2022-07-11)`);
+
+        line = '- [ ] some task tod';
+        suggestions = buildSuggestions(line, 19, originalSettings);
+        expect(suggestions[0].suggestionType).toEqual('empty');
+        expect(suggestions[0].displayText).not.toContain('created today');
+    });
+
     // Until the maximum date suggestion number is no longer hardcoded,
     // it's not possible to output all dates. Skipping for now.
     it.skip('show all suggested text', () => {
diff --git a/tests/global-setup.js b/tests/global-setup.js
new file mode 100644
index 00000000..3d1e8924
--- /dev/null
+++ b/tests/global-setup.js
@@ -0,0 +1,3 @@
+module.exports = async () => {
+    process.env.TZ = 'UTC';
+};