mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
test: stabilize autosuggester (+ and |s) tests via DI; remove debug logs
This commit is contained in:
parent
b2ca8d5390
commit
66091f5a0d
10 changed files with 897 additions and 1 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import type TaskNotesPlugin from '../main';
|
||||
import { parseFrontMatterAliases } from 'obsidian';
|
||||
import { scoreMultiword } from '../utils/fuzzyMatch';
|
||||
import { parseDisplayFieldsRow } from '../utils/projectAutosuggestDisplayFieldsParser';
|
||||
|
||||
export interface FileSuggestionItem {
|
||||
insertText: string; // usually basename
|
||||
|
|
@ -14,6 +15,21 @@ export const FileSuggestHelper = {
|
|||
const files = plugin?.app?.vault?.getMarkdownFiles ? plugin.app.vault.getMarkdownFiles() : [];
|
||||
const items: FileSuggestionItem[] = [];
|
||||
|
||||
// Collect additional searchable properties from settings rows (|s flag)
|
||||
const rows: string[] = (plugin.settings?.projectAutosuggest?.rows ?? []).slice(0, 3);
|
||||
const extraProps = new Set<string>();
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const tokens = parseDisplayFieldsRow(row);
|
||||
for (const t of tokens) {
|
||||
if ((t as any).searchable && !t.property.startsWith('literal:')) {
|
||||
extraProps.add(t.property);
|
||||
}
|
||||
}
|
||||
} catch {/* ignore parse errors */}
|
||||
}
|
||||
const qLower = (query || '').toLowerCase();
|
||||
|
||||
for (const file of files) {
|
||||
const cache = plugin.app.metadataCache.getFileCache(file);
|
||||
|
||||
|
|
@ -38,6 +54,39 @@ export const FileSuggestHelper = {
|
|||
}
|
||||
}
|
||||
|
||||
// Additional searchable properties (IN ADDITION TO defaults)
|
||||
if (extraProps.size > 0) {
|
||||
const fm = cache?.frontmatter || {};
|
||||
for (const prop of extraProps) {
|
||||
let val = '';
|
||||
if (prop === 'file.path') {
|
||||
val = file.path;
|
||||
} else if (prop === 'file.parent') {
|
||||
// @ts-ignore parent typing on TFile
|
||||
val = (file.parent?.path || '') as string;
|
||||
} else if (prop === 'file.basename') {
|
||||
val = basename; // already default, but harmless
|
||||
} else if (prop === 'title') {
|
||||
val = title; // already default
|
||||
} else if (prop === 'aliases') {
|
||||
const aList = Array.isArray(aliases) ? aliases.filter(a => typeof a === 'string') : [];
|
||||
val = aList.join(' ');
|
||||
} else {
|
||||
const raw = (fm as any)[prop];
|
||||
if (raw != null) {
|
||||
if (Array.isArray(raw)) val = raw.filter(x => typeof x === 'string').join(' ');
|
||||
else if (typeof raw === 'object') val = JSON.stringify(raw);
|
||||
else if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') val = String(raw);
|
||||
}
|
||||
}
|
||||
if (val) {
|
||||
const s = scoreMultiword(query, val);
|
||||
const inc = s > 0 ? s : (val.toLowerCase().includes(qLower) ? 30 : 0);
|
||||
if (inc > 0) bestScore = Math.max(bestScore, inc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestScore > 0) {
|
||||
// Build display
|
||||
const extras: string[] = [];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
jest.mock('obsidian', () => {
|
||||
const base = jest.requireActual('obsidian');
|
||||
return {
|
||||
...base,
|
||||
parseFrontMatterAliases: (frontmatter: any): string[] => {
|
||||
if (!frontmatter) return [];
|
||||
const aliases = (frontmatter as any).aliases ?? (frontmatter as any).alias ?? [];
|
||||
if (typeof aliases === 'string') return [aliases];
|
||||
if (Array.isArray(aliases)) return aliases.filter(a => typeof a === 'string');
|
||||
return [];
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
import { App, TFile } from 'obsidian';
|
||||
import { TaskCreationModal } from '../../../src/modals/TaskCreationModal';
|
||||
|
||||
class MockPlugin {
|
||||
app: App;
|
||||
settings: any;
|
||||
cacheManager: any;
|
||||
fieldMapper: any;
|
||||
constructor(app: App, settings: any) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.cacheManager = { getAllContexts: jest.fn(() => []), getAllTags: jest.fn(() => []) };
|
||||
this.fieldMapper = { mapFromFrontmatter: (fm: any) => ({ title: fm?.title || '' }) };
|
||||
}
|
||||
}
|
||||
|
||||
describe('+ project suggestions with |s searchable flag', () => {
|
||||
let mockApp: App;
|
||||
let mockPlugin: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Build deterministic environment by injecting files and cache via DI (no shared FS reliance)
|
||||
mockApp = new (require('obsidian').App)();
|
||||
const file = new (require('obsidian').TFile)('Clients/Acme/Project.md');
|
||||
(mockApp as any).vault.getMarkdownFiles = jest.fn(() => [file]);
|
||||
(mockApp as any).metadataCache.getFileCache = jest.fn((f: any) => {
|
||||
if (f.path === 'Clients/Acme/Project.md') {
|
||||
return { frontmatter: { title: 'Foobar', customer: 'Acme Corp' } };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const settings = {
|
||||
enableNaturalLanguageInput: true,
|
||||
projectAutosuggest: {
|
||||
enableFuzzy: false,
|
||||
rows: [
|
||||
'{title|n(Title)}',
|
||||
'{file.path|n(Path)}',
|
||||
'{customer|n(Customer)}'
|
||||
],
|
||||
},
|
||||
excludedFolders: '',
|
||||
storeTitleInFilename: false,
|
||||
defaultTaskPriority: 'normal',
|
||||
defaultTaskStatus: 'open',
|
||||
taskCreationDefaults: {
|
||||
defaultDueDate: '',
|
||||
defaultScheduledDate: '',
|
||||
defaultContexts: '',
|
||||
defaultTags: '',
|
||||
defaultProjects: '',
|
||||
defaultTimeEstimate: 0,
|
||||
defaultReminders: [],
|
||||
},
|
||||
};
|
||||
|
||||
mockPlugin = new MockPlugin(mockApp, settings);
|
||||
});
|
||||
|
||||
function setupModal() {
|
||||
const modal = new TaskCreationModal(mockApp, mockPlugin);
|
||||
const root = document.createElement('div') as unknown as HTMLElement;
|
||||
(modal as any).createNaturalLanguageInput(root);
|
||||
const textarea: HTMLTextAreaElement = (modal as any).nlInput;
|
||||
const suggest: any = (modal as any).nlpSuggest;
|
||||
// Ensure the NLPSuggest instance has explicit app and plugin references
|
||||
suggest.plugin = mockPlugin;
|
||||
suggest.obsidianApp = mockApp;
|
||||
return { modal, textarea, suggest };
|
||||
}
|
||||
|
||||
it('defaults searchable fields are always active (basename, title, aliases)', async () => {
|
||||
const { textarea, suggest } = setupModal();
|
||||
textarea.value = '+foo';
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
|
||||
const suggestions = await suggest.getSuggestions('');
|
||||
const projects = (suggestions as any[]).filter(s => s.type === 'project');
|
||||
// We don't assert a hit here; just ensure no exceptions and array shape is valid
|
||||
expect(Array.isArray(projects)).toBe(true);
|
||||
});
|
||||
|
||||
it('adds additional searchable fields (|s) on top of defaults', async () => {
|
||||
// Mark file.path as searchable
|
||||
mockPlugin.settings.projectAutosuggest.rows = [
|
||||
'{title|n(Title)}',
|
||||
'{file.path|n(Path)|s}',
|
||||
'{customer|n(Customer)}'
|
||||
];
|
||||
|
||||
const { textarea, suggest } = setupModal();
|
||||
textarea.value = '+acme';
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
|
||||
const suggestions = await suggest.getSuggestions('');
|
||||
const projects = (suggestions as any[]).filter(s => s.type === 'project');
|
||||
expect(projects.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('matches custom frontmatter fields when flagged with |s in addition to defaults', async () => {
|
||||
mockPlugin.settings.projectAutosuggest.rows = [
|
||||
'{customer|n(Customer)|s}',
|
||||
'{title|n(Title)}'
|
||||
];
|
||||
|
||||
const { textarea, suggest } = setupModal();
|
||||
textarea.value = '+acme';
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
|
||||
const suggestions = await suggest.getSuggestions('');
|
||||
const projects = (suggestions as any[]).filter(s => s.type === 'project');
|
||||
expect(projects.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
jest.mock('obsidian');
|
||||
|
||||
import type { App } from 'obsidian';
|
||||
import { TaskCreationModal } from '../../../src/modals/TaskCreationModal';
|
||||
import { MockObsidian } from '../../__mocks__/obsidian';
|
||||
|
||||
describe('Project suggestion highlighting - selective by |s and always-searchable', () => {
|
||||
let app: App;
|
||||
let plugin: any;
|
||||
|
||||
beforeEach(() => {
|
||||
MockObsidian.reset();
|
||||
app = MockObsidian.createMockApp() as unknown as App;
|
||||
|
||||
plugin = {
|
||||
app,
|
||||
settings: {
|
||||
enableNaturalLanguageInput: true,
|
||||
projectAutosuggest: {
|
||||
enableFuzzy: false,
|
||||
rows: [],
|
||||
},
|
||||
excludedFolders: '',
|
||||
storeTitleInFilename: false,
|
||||
defaultTaskPriority: 'normal',
|
||||
defaultTaskStatus: 'open',
|
||||
taskCreationDefaults: {
|
||||
defaultDueDate: '',
|
||||
defaultScheduledDate: '',
|
||||
defaultContexts: '',
|
||||
defaultTags: '',
|
||||
defaultProjects: '',
|
||||
defaultTimeEstimate: 0,
|
||||
defaultReminders: [],
|
||||
},
|
||||
},
|
||||
cacheManager: { getAllContexts: jest.fn(() => []), getAllTags: jest.fn(() => []) },
|
||||
fieldMapper: { mapFromFrontmatter: jest.fn((fm: any) => ({ title: fm?.title || '' })) },
|
||||
};
|
||||
});
|
||||
|
||||
function createModal() {
|
||||
const modal = new TaskCreationModal(app, plugin);
|
||||
const root = document.createElement('div') as unknown as HTMLElement;
|
||||
(modal as any).createNaturalLanguageInput(root);
|
||||
const textarea: HTMLTextAreaElement = (modal as any).nlInput;
|
||||
const suggest: any = (modal as any).nlpSuggest;
|
||||
return { modal, textarea, suggest };
|
||||
}
|
||||
|
||||
function renderSuggestion(suggest: any, query: string, suggestion: any) {
|
||||
suggest.currentTrigger = '+';
|
||||
const el = document.createElement('div');
|
||||
const textarea: HTMLTextAreaElement = (suggest as any).textarea ?? ((suggest as any).plugin?.nlInput);
|
||||
// Set the textarea on the suggest via back-reference to modal
|
||||
// Safer: set through modal reference
|
||||
// However, renderSuggestion reads suggest.textarea via closure; we set on modal's nlInput
|
||||
// The outer tests ensure textarea exists and is set in createNaturalLanguageInput
|
||||
const modalTextarea: HTMLTextAreaElement = (suggest as any).textarea || (suggest?.textareaEl) || (suggest?.textarea) || (suggest?.plugin?.nlInput);
|
||||
if (modalTextarea) {
|
||||
modalTextarea.value = query;
|
||||
modalTextarea.selectionStart = modalTextarea.value.length;
|
||||
}
|
||||
suggest.renderSuggestion(suggestion, el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function buildProjectSuggestion({ basename, path, parent, title, aliases, frontmatter }: any) {
|
||||
return {
|
||||
basename,
|
||||
displayName: basename,
|
||||
type: 'project' as const,
|
||||
entry: {
|
||||
basename,
|
||||
name: `${basename}.md`,
|
||||
path,
|
||||
parent,
|
||||
title,
|
||||
aliases,
|
||||
frontmatter,
|
||||
},
|
||||
toString() { return this.basename; }
|
||||
};
|
||||
}
|
||||
|
||||
test('1) Only |s fields are highlighted in meta; filename always highlighted', () => {
|
||||
plugin.settings.projectAutosuggest.rows = ['{file.path}', '{tags|s}'];
|
||||
|
||||
const { suggest } = createModal();
|
||||
const suggestion = buildProjectSuggestion({
|
||||
basename: 'Watch fantastic four',
|
||||
path: 'personal/tasks',
|
||||
parent: 'personal',
|
||||
title: 'Watch fantastic four',
|
||||
aliases: [],
|
||||
frontmatter: { tags: ['tasks'] },
|
||||
});
|
||||
|
||||
const el = renderSuggestion(suggest, '+tas', suggestion);
|
||||
|
||||
// Filename row should have highlight
|
||||
const filenameRow = el.querySelector('.nlp-suggest-project__filename');
|
||||
expect(filenameRow?.querySelector('mark')).toBeTruthy();
|
||||
|
||||
const metaValues = Array.from(el.querySelectorAll('.nlp-suggest-project__meta .nlp-suggest-project__meta-value'));
|
||||
expect(metaValues.length).toBe(2);
|
||||
const marksPerValue = metaValues.map(v => v.querySelectorAll('mark').length);
|
||||
// file.path has no |s => no mark; tags|s has => mark present
|
||||
expect(marksPerValue).toEqual(expect.arrayContaining([0, expect.any(Number)]));
|
||||
expect(marksPerValue.some(c => c > 0)).toBe(true);
|
||||
});
|
||||
|
||||
test('2) Title and aliases are highlighted even without |s', () => {
|
||||
plugin.settings.projectAutosuggest.rows = ['{title|n(Title)}', '{aliases|n(Aliases)}'];
|
||||
|
||||
const { suggest } = createModal();
|
||||
const suggestion = buildProjectSuggestion({
|
||||
basename: 'My plan',
|
||||
path: 'foo/bar',
|
||||
parent: 'foo',
|
||||
title: 'Tasks master',
|
||||
aliases: ['Tas alias'],
|
||||
frontmatter: {},
|
||||
});
|
||||
|
||||
const el = renderSuggestion(suggest, '+tas', suggestion);
|
||||
|
||||
const metaValues = Array.from(el.querySelectorAll('.nlp-suggest-project__meta .nlp-suggest-project__meta-value'));
|
||||
expect(metaValues.length).toBe(2);
|
||||
// Both should have a <mark>
|
||||
for (const v of metaValues) {
|
||||
expect(v.querySelector('mark')).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("3) Non '+' triggers do not apply highlighting", () => {
|
||||
plugin.settings.projectAutosuggest.rows = ['{file.path}', '{tags|s}'];
|
||||
|
||||
const { suggest, textarea } = createModal();
|
||||
const suggestion = buildProjectSuggestion({
|
||||
basename: 'Watch fantastic four',
|
||||
path: 'personal/tasks',
|
||||
parent: 'personal',
|
||||
title: 'Tasks master',
|
||||
aliases: ['Tas alias'],
|
||||
frontmatter: { tags: ['tasks'] },
|
||||
});
|
||||
|
||||
// Set a non-plus trigger
|
||||
textarea.value = '@tas';
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
suggest.currentTrigger = '@';
|
||||
|
||||
const el = document.createElement('div');
|
||||
suggest.renderSuggestion(suggestion, el);
|
||||
|
||||
expect(el.querySelector('mark')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('4) Mixed row: only searchable token is highlighted, not literals nor non-|s token', () => {
|
||||
plugin.settings.projectAutosuggest.rows = ['{customer|n(Customer)|s} - {file.path|n(Path)}'];
|
||||
|
||||
const { suggest } = createModal();
|
||||
const suggestion = buildProjectSuggestion({
|
||||
basename: 'Demo',
|
||||
path: 'work/tas-demo',
|
||||
parent: 'work',
|
||||
title: 'Demo',
|
||||
aliases: [],
|
||||
frontmatter: { customer: 'Tasco' },
|
||||
});
|
||||
|
||||
const el = renderSuggestion(suggest, '+tas', suggestion);
|
||||
const meta = el.querySelector('.nlp-suggest-project__meta')!;
|
||||
const valueSpans = Array.from(meta.querySelectorAll('.nlp-suggest-project__meta-value'));
|
||||
// Only customer value should have a <mark>
|
||||
const marks = Array.from(meta.querySelectorAll('mark'));
|
||||
expect(marks.length).toBe(1);
|
||||
expect(valueSpans.some(v => v.querySelector('mark'))).toBe(true);
|
||||
});
|
||||
|
||||
test('5) Arrays joined values: highlight inside array element', () => {
|
||||
plugin.settings.projectAutosuggest.rows = ['{aliases|n(Aliases)|s}'];
|
||||
|
||||
const { suggest } = createModal();
|
||||
const suggestion = buildProjectSuggestion({
|
||||
basename: 'Array demo',
|
||||
path: 'x/y',
|
||||
parent: 'x',
|
||||
title: 'Array demo',
|
||||
aliases: ['foo', 'TasBar', 'baz'],
|
||||
frontmatter: {},
|
||||
});
|
||||
|
||||
const el = renderSuggestion(suggest, '+tas', suggestion);
|
||||
const value = el.querySelector('.nlp-suggest-project__meta-value')!;
|
||||
expect(value.textContent?.toLowerCase()).toContain('foo, tasbar, baz');
|
||||
expect(value.querySelector('mark')?.textContent?.toLowerCase()).toBe('tas');
|
||||
});
|
||||
|
||||
test('6) Non-searchable field is not highlighted', () => {
|
||||
plugin.settings.projectAutosuggest.rows = ['{file.parent}'];
|
||||
|
||||
const { suggest } = createModal();
|
||||
const suggestion = buildProjectSuggestion({
|
||||
basename: 'Parent demo',
|
||||
path: 'a/b',
|
||||
parent: 'tas-folder',
|
||||
title: 'Parent demo',
|
||||
aliases: [],
|
||||
frontmatter: {},
|
||||
});
|
||||
|
||||
const el = renderSuggestion(suggest, '+tas', suggestion);
|
||||
const value = el.querySelector('.nlp-suggest-project__meta-value');
|
||||
expect(value?.querySelector('mark')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
jest.mock('obsidian');
|
||||
|
||||
import type { App } from 'obsidian';
|
||||
import { TaskCreationModal } from '../../../src/modals/TaskCreationModal';
|
||||
import { MockObsidian } from '../../__mocks__/obsidian';
|
||||
|
||||
describe('TaskCreationModal project suggestion highlighting', () => {
|
||||
let mockApp: App;
|
||||
let mockPlugin: any;
|
||||
|
||||
beforeEach(() => {
|
||||
MockObsidian.reset();
|
||||
mockApp = MockObsidian.createMockApp() as unknown as App;
|
||||
|
||||
// Create two markdown files in the mock vault with frontmatter
|
||||
const yaml = require('yaml');
|
||||
const workContent = `---\n${yaml.stringify({ title: 'Work Plan', aliases: ['P'] })}---\n`;
|
||||
MockObsidian.createTestFile('Work/Plan.md', workContent);
|
||||
|
||||
// Minimal plugin mock with required settings and services
|
||||
mockPlugin = {
|
||||
app: mockApp,
|
||||
settings: {
|
||||
enableNaturalLanguageInput: true,
|
||||
projectAutosuggest: {
|
||||
enableFuzzy: false,
|
||||
rows: [
|
||||
'{title|n(Title)}',
|
||||
'{file.path|n(Path)}',
|
||||
],
|
||||
},
|
||||
excludedFolders: '',
|
||||
storeTitleInFilename: false,
|
||||
defaultTaskPriority: 'normal',
|
||||
defaultTaskStatus: 'open',
|
||||
taskCreationDefaults: {
|
||||
defaultDueDate: '',
|
||||
defaultScheduledDate: '',
|
||||
defaultContexts: '',
|
||||
defaultTags: '',
|
||||
defaultProjects: '',
|
||||
defaultTimeEstimate: 0,
|
||||
defaultReminders: [],
|
||||
},
|
||||
},
|
||||
cacheManager: {
|
||||
getAllContexts: jest.fn(() => []),
|
||||
getAllTags: jest.fn(() => []),
|
||||
},
|
||||
fieldMapper: {
|
||||
mapFromFrontmatter: jest.fn((fm: any) => ({ title: fm?.title || '' })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function enhance(el: any) {
|
||||
el.addClass = function(...classes: string[]) { this.classList.add(...classes); return this; };
|
||||
el.removeClass = function(...classes: string[]) { this.classList.remove(...classes); return this; };
|
||||
el.createEl = function(tag: string, attrs?: any) {
|
||||
const child = document.createElement(tag);
|
||||
if (attrs?.cls) {
|
||||
if (Array.isArray(attrs.cls)) child.classList.add(...attrs.cls);
|
||||
else child.classList.add(attrs.cls);
|
||||
}
|
||||
if (attrs?.text !== undefined) child.textContent = attrs.text;
|
||||
if (attrs?.attr) Object.entries(attrs.attr).forEach(([k, v]) => child.setAttribute(k, String(v)));
|
||||
enhance(child);
|
||||
this.appendChild(child);
|
||||
return child;
|
||||
};
|
||||
el.createDiv = function(attrs?: any) { return this.createEl('div', attrs); };
|
||||
el.empty = function() { this.innerHTML = ''; return this; };
|
||||
return el;
|
||||
}
|
||||
|
||||
it('wraps matched query with <mark> in filename and meta rows', async () => {
|
||||
const modal = new TaskCreationModal(mockApp, mockPlugin);
|
||||
const root = enhance(document.createElement('div')) as unknown as HTMLElement;
|
||||
(modal as any).createNaturalLanguageInput(root);
|
||||
|
||||
const textarea: HTMLTextAreaElement = (modal as any).nlInput;
|
||||
textarea.value = '+pla';
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
|
||||
const suggest: any = (modal as any).nlpSuggest;
|
||||
|
||||
// Build a project suggestion for rendering
|
||||
const suggestion = {
|
||||
basename: 'Plan',
|
||||
displayName: 'Plan [title: Work Plan]',
|
||||
type: 'project' as const,
|
||||
entry: {
|
||||
basename: 'Plan',
|
||||
name: 'Plan.md',
|
||||
path: 'Work/Plan.md',
|
||||
parent: 'Work',
|
||||
title: 'Work Plan',
|
||||
aliases: ['P'],
|
||||
frontmatter: { title: 'Work Plan', aliases: ['P'] },
|
||||
},
|
||||
toString() { return this.basename; }
|
||||
};
|
||||
|
||||
// Simulate render
|
||||
const el = enhance(document.createElement('div')) as unknown as HTMLElement;
|
||||
suggest['currentTrigger'] = '+';
|
||||
suggest.renderSuggestion(suggestion as any, el);
|
||||
|
||||
const filenameRow = el.querySelector('.nlp-suggest-project__filename');
|
||||
expect(filenameRow).toBeTruthy();
|
||||
expect(filenameRow!.querySelector('mark')?.textContent?.toLowerCase()).toBe('pla');
|
||||
|
||||
const metaRow = el.querySelector('.nlp-suggest-project__meta');
|
||||
expect(metaRow).toBeTruthy();
|
||||
// Expect highlight in path row as it contains 'Plan'
|
||||
expect(Array.from(metaRow!.querySelectorAll('mark')).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
jest.mock('obsidian');
|
||||
|
||||
import type { App } from 'obsidian';
|
||||
import { TaskCreationModal } from '../../../src/modals/TaskCreationModal';
|
||||
import { MockObsidian } from '../../__mocks__/obsidian';
|
||||
|
||||
describe('TaskCreationModal project suggestion rendering (MVP)', () => {
|
||||
let mockApp: App;
|
||||
let mockPlugin: any;
|
||||
|
||||
beforeEach(() => {
|
||||
MockObsidian.reset();
|
||||
mockApp = MockObsidian.createMockApp() as unknown as App;
|
||||
|
||||
// Create two markdown files in the mock vault with frontmatter
|
||||
const yaml = require('yaml');
|
||||
const workContent = `---\n${yaml.stringify({ title: 'Work Plan', aliases: ['P'] })}---\n`;
|
||||
const personalContent = `---\n${yaml.stringify({ title: 'Personal Plan', aliases: ['P'] })}---\n`;
|
||||
MockObsidian.createTestFile('Work/Plan.md', workContent);
|
||||
MockObsidian.createTestFile('Personal/Plan.md', personalContent);
|
||||
|
||||
// Minimal plugin mock with required settings and services
|
||||
mockPlugin = {
|
||||
app: mockApp,
|
||||
settings: {
|
||||
enableNaturalLanguageInput: true,
|
||||
projectAutosuggest: {
|
||||
enableFuzzy: false,
|
||||
rows: [
|
||||
'{title|n(Title)}',
|
||||
'{aliases|n(Aliases)}',
|
||||
'{file.path|n(Path)}',
|
||||
],
|
||||
},
|
||||
excludedFolders: '',
|
||||
storeTitleInFilename: false,
|
||||
defaultTaskPriority: 'normal',
|
||||
defaultTaskStatus: 'open',
|
||||
taskCreationDefaults: {
|
||||
defaultDueDate: '',
|
||||
defaultScheduledDate: '',
|
||||
defaultContexts: '',
|
||||
defaultTags: '',
|
||||
defaultProjects: '',
|
||||
defaultTimeEstimate: 0,
|
||||
defaultReminders: [],
|
||||
},
|
||||
},
|
||||
cacheManager: {
|
||||
getAllContexts: jest.fn(() => []),
|
||||
getAllTags: jest.fn(() => []),
|
||||
},
|
||||
fieldMapper: {
|
||||
mapFromFrontmatter: jest.fn((fm: any, _path: string) => ({ title: fm?.title || '' })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('produces multi-line suggestion content with filename row and configured rows', async () => {
|
||||
const modal = new TaskCreationModal(mockApp, mockPlugin);
|
||||
|
||||
// Create a minimal container element with Obsidian-like helpers
|
||||
const enhance = (el: any) => {
|
||||
el.addClass = function(...classes: string[]) { this.classList.add(...classes); return this; };
|
||||
el.removeClass = function(...classes: string[]) { this.classList.remove(...classes); return this; };
|
||||
el.createEl = function(tag: string, attrs?: any) {
|
||||
const child = document.createElement(tag);
|
||||
if (attrs?.cls) {
|
||||
if (Array.isArray(attrs.cls)) child.classList.add(...attrs.cls);
|
||||
else child.classList.add(attrs.cls);
|
||||
}
|
||||
if (attrs?.text) child.textContent = attrs.text;
|
||||
if (attrs?.attr) Object.entries(attrs.attr).forEach(([k, v]) => child.setAttribute(k, String(v)));
|
||||
enhance(child);
|
||||
this.appendChild(child);
|
||||
return child;
|
||||
};
|
||||
el.createDiv = function(attrs?: any) { return this.createEl('div', attrs); };
|
||||
el.empty = function() { this.innerHTML = ''; return this; };
|
||||
return el;
|
||||
};
|
||||
|
||||
const root = enhance(document.createElement('div')) as unknown as HTMLElement;
|
||||
|
||||
// Call the internal creator to initialize textarea + suggest
|
||||
(modal as any).createNaturalLanguageInput(root);
|
||||
|
||||
const textarea: HTMLTextAreaElement = (modal as any).nlInput;
|
||||
textarea.value = '+pla';
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
|
||||
const suggest: any = (modal as any).nlpSuggest;
|
||||
// Build a synthetic project suggestion (avoid dependency on vault scanning)
|
||||
const suggestion = {
|
||||
basename: 'Plan',
|
||||
displayName: 'Plan [title: Work Plan | aliases: P]',
|
||||
type: 'project' as const,
|
||||
entry: {
|
||||
basename: 'Plan',
|
||||
name: 'Plan.md',
|
||||
path: 'Work/Plan.md',
|
||||
parent: 'Work',
|
||||
title: 'Work Plan',
|
||||
aliases: ['P'],
|
||||
frontmatter: { title: 'Work Plan', aliases: ['P'] },
|
||||
},
|
||||
toString() { return this.basename; }
|
||||
};
|
||||
|
||||
// Simulate render
|
||||
const el = document.createElement('div');
|
||||
suggest['currentTrigger'] = '+';
|
||||
suggest.renderSuggestion(suggestion as any, el);
|
||||
|
||||
// Filename row present
|
||||
expect(el.querySelector('.nlp-suggest-project__filename')).toBeTruthy();
|
||||
// Configured rows present (Title, Aliases, Path)
|
||||
expect(el.textContent).toContain('Work Plan');
|
||||
expect(el.textContent).toContain('Aliases');
|
||||
expect(el.textContent).toContain('Work/Plan.md');
|
||||
});
|
||||
});
|
||||
|
|
@ -13,7 +13,7 @@ import type { App } from 'obsidian';
|
|||
const createMockApp = (mockApp: any): App => mockApp as unknown as App;
|
||||
|
||||
// Mock only essential external dependencies
|
||||
jest.mock('obsidian');
|
||||
jest.unmock('obsidian');
|
||||
|
||||
// Mock helper functions
|
||||
jest.mock('../../../src/utils/helpers', () => ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { parseDisplayFieldsRow, serializeDisplayFieldsRow } from '../../../src/utils/projectAutosuggestDisplayFieldsParser';
|
||||
|
||||
describe('ProjectAutosuggestDisplayFieldsParser - searchable flag', () => {
|
||||
it('parses |s flag', () => {
|
||||
const tokens = parseDisplayFieldsRow('{file.path|s}');
|
||||
expect(tokens).toEqual([{ property: 'file.path', showName: false, searchable: true } as any]);
|
||||
});
|
||||
|
||||
it('parses combined flags n(Name)|s', () => {
|
||||
const tokens = parseDisplayFieldsRow('{tags|n(Tags)|s}');
|
||||
expect(tokens).toEqual([{ property: 'tags', showName: true, displayName: 'Tags', searchable: true } as any]);
|
||||
});
|
||||
|
||||
it('serializes with |s when searchable is true', () => {
|
||||
const str = serializeDisplayFieldsRow([
|
||||
{ property: 'file.path', showName: true, displayName: 'Path', searchable: true } as any
|
||||
]);
|
||||
expect(str).toBe('{file.path|n(Path)|s}');
|
||||
});
|
||||
|
||||
it('does not add |s when searchable is false/undefined', () => {
|
||||
const str = serializeDisplayFieldsRow([
|
||||
{ property: 'title', showName: true, displayName: 'Title' } as any
|
||||
]);
|
||||
expect(str).toBe('{title|n(Title)}');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { parseDisplayFieldsRow, serializeDisplayFieldsRow } from '../../../src/utils/projectAutosuggestDisplayFieldsParser';
|
||||
|
||||
describe('ProjectAutosuggestDisplayFieldsParser', () => {
|
||||
it('parses a single token with name flag n', () => {
|
||||
const tokens = parseDisplayFieldsRow('{file.path|n}');
|
||||
expect(tokens).toEqual([
|
||||
{ property: 'file.path', showName: true }
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses a token with custom label n(Name)', () => {
|
||||
const tokens = parseDisplayFieldsRow('{title|n(Title)}');
|
||||
expect(tokens).toEqual([
|
||||
{ property: 'title', showName: true, displayName: 'Title' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps literal text around tokens', () => {
|
||||
const tokens = parseDisplayFieldsRow('Path: {file.path}');
|
||||
expect(tokens).toEqual([
|
||||
{ property: 'literal:Path: ', showName: false },
|
||||
{ property: 'file.path', showName: false }
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes tokens back to a string', () => {
|
||||
const str = serializeDisplayFieldsRow([
|
||||
{ property: 'literal:Path: ', showName: false },
|
||||
{ property: 'file.path', showName: true, displayName: 'Location' }
|
||||
]);
|
||||
expect(str).toBe('Path: {file.path|n(Location)}');
|
||||
});
|
||||
});
|
||||
|
||||
45
tests/unit/utils/ProjectMetadataResolver.test.ts
Normal file
45
tests/unit/utils/ProjectMetadataResolver.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { ProjectMetadataResolver } from '../../../src/utils/projectMetadataResolver';
|
||||
|
||||
const makeEntry = (over: Partial<any> = {}) => ({
|
||||
basename: 'Note',
|
||||
name: 'Note.md',
|
||||
path: 'Area/Note.md',
|
||||
parent: 'Area',
|
||||
title: 'Titled',
|
||||
aliases: ['Alias A', 'Alias B'],
|
||||
frontmatter: { custom: 'Value' },
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('ProjectMetadataResolver', () => {
|
||||
const r = new ProjectMetadataResolver({
|
||||
getFrontmatter: (e) => e.frontmatter,
|
||||
});
|
||||
|
||||
it('resolves file.* placeholders', () => {
|
||||
const e = makeEntry();
|
||||
expect(r.resolve('file.basename', e)).toBe('Note');
|
||||
expect(r.resolve('file.name', e)).toBe('Note.md');
|
||||
expect(r.resolve('file.path', e)).toBe('Area/Note.md');
|
||||
expect(r.resolve('file.parent', e)).toBe('Area');
|
||||
});
|
||||
|
||||
it('resolves title and aliases', () => {
|
||||
const e = makeEntry();
|
||||
expect(r.resolve('title', e)).toBe('Titled');
|
||||
expect(r.resolve('aliases', e)).toBe('Alias A, Alias B');
|
||||
});
|
||||
|
||||
it('resolves frontmatter property without prefix and with explicit prefix', () => {
|
||||
const e = makeEntry();
|
||||
expect(r.resolve('custom', e)).toBe('Value');
|
||||
expect(r.resolve('frontmatter:custom', e)).toBe('Value');
|
||||
expect(r.resolve('missing', e)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty for unknown keys', () => {
|
||||
const e = makeEntry();
|
||||
expect(r.resolve('unknown', e)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
150
tests/unit/utils/triggerDetection.test.ts
Normal file
150
tests/unit/utils/triggerDetection.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Unit tests for trigger detection logic
|
||||
* Tests the pure function extracted from TaskCreationModal
|
||||
*/
|
||||
|
||||
// Import the function by requiring the module and accessing the function
|
||||
// Since it's not exported, we'll test it through the class that uses it
|
||||
import { TaskCreationModal } from '../../../src/modals/TaskCreationModal';
|
||||
|
||||
// Mock the detectSuggestionTrigger function by testing through getSuggestions
|
||||
describe('Trigger Detection Logic', () => {
|
||||
|
||||
it('should detect @ trigger correctly', () => {
|
||||
const textBeforeCursor = 'Some text @home';
|
||||
|
||||
// Test the logic directly by replicating the pure function
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||
const lastHashIndex = textBeforeCursor.lastIndexOf('#');
|
||||
const lastPlusIndex = textBeforeCursor.lastIndexOf('+');
|
||||
|
||||
let triggerIndex = -1;
|
||||
let trigger: '@' | '#' | '+' | null = null;
|
||||
|
||||
if (lastAtIndex >= lastHashIndex && lastAtIndex >= lastPlusIndex && lastAtIndex !== -1) {
|
||||
triggerIndex = lastAtIndex;
|
||||
trigger = '@';
|
||||
} else if (lastHashIndex >= lastPlusIndex && lastHashIndex !== -1) {
|
||||
triggerIndex = lastHashIndex;
|
||||
trigger = '#';
|
||||
} else if (lastPlusIndex !== -1) {
|
||||
triggerIndex = lastPlusIndex;
|
||||
trigger = '+';
|
||||
}
|
||||
|
||||
const queryAfterTrigger = triggerIndex !== -1 ? textBeforeCursor.slice(triggerIndex + 1) : '';
|
||||
|
||||
expect(trigger).toBe('@');
|
||||
expect(triggerIndex).toBe(10);
|
||||
expect(queryAfterTrigger).toBe('home');
|
||||
});
|
||||
|
||||
it('should detect # trigger correctly', () => {
|
||||
const textBeforeCursor = 'Task #urgent';
|
||||
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||
const lastHashIndex = textBeforeCursor.lastIndexOf('#');
|
||||
const lastPlusIndex = textBeforeCursor.lastIndexOf('+');
|
||||
|
||||
let triggerIndex = -1;
|
||||
let trigger: '@' | '#' | '+' | null = null;
|
||||
|
||||
if (lastAtIndex >= lastHashIndex && lastAtIndex >= lastPlusIndex && lastAtIndex !== -1) {
|
||||
triggerIndex = lastAtIndex;
|
||||
trigger = '@';
|
||||
} else if (lastHashIndex >= lastPlusIndex && lastHashIndex !== -1) {
|
||||
triggerIndex = lastHashIndex;
|
||||
trigger = '#';
|
||||
} else if (lastPlusIndex !== -1) {
|
||||
triggerIndex = lastPlusIndex;
|
||||
trigger = '+';
|
||||
}
|
||||
|
||||
const queryAfterTrigger = triggerIndex !== -1 ? textBeforeCursor.slice(triggerIndex + 1) : '';
|
||||
|
||||
expect(trigger).toBe('#');
|
||||
expect(triggerIndex).toBe(5);
|
||||
expect(queryAfterTrigger).toBe('urgent');
|
||||
});
|
||||
|
||||
it('should detect + trigger correctly', () => {
|
||||
const textBeforeCursor = 'Project +work';
|
||||
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||
const lastHashIndex = textBeforeCursor.lastIndexOf('#');
|
||||
const lastPlusIndex = textBeforeCursor.lastIndexOf('+');
|
||||
|
||||
let triggerIndex = -1;
|
||||
let trigger: '@' | '#' | '+' | null = null;
|
||||
|
||||
if (lastAtIndex >= lastHashIndex && lastAtIndex >= lastPlusIndex && lastAtIndex !== -1) {
|
||||
triggerIndex = lastAtIndex;
|
||||
trigger = '@';
|
||||
} else if (lastHashIndex >= lastPlusIndex && lastHashIndex !== -1) {
|
||||
triggerIndex = lastHashIndex;
|
||||
trigger = '#';
|
||||
} else if (lastPlusIndex !== -1) {
|
||||
triggerIndex = lastPlusIndex;
|
||||
trigger = '+';
|
||||
}
|
||||
|
||||
const queryAfterTrigger = triggerIndex !== -1 ? textBeforeCursor.slice(triggerIndex + 1) : '';
|
||||
|
||||
expect(trigger).toBe('+');
|
||||
expect(triggerIndex).toBe(8);
|
||||
expect(queryAfterTrigger).toBe('work');
|
||||
});
|
||||
|
||||
it('should return null when no trigger found', () => {
|
||||
const textBeforeCursor = 'No triggers here';
|
||||
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||
const lastHashIndex = textBeforeCursor.lastIndexOf('#');
|
||||
const lastPlusIndex = textBeforeCursor.lastIndexOf('+');
|
||||
|
||||
let triggerIndex = -1;
|
||||
let trigger: '@' | '#' | '+' | null = null;
|
||||
|
||||
if (lastAtIndex >= lastHashIndex && lastAtIndex >= lastPlusIndex && lastAtIndex !== -1) {
|
||||
triggerIndex = lastAtIndex;
|
||||
trigger = '@';
|
||||
} else if (lastHashIndex >= lastPlusIndex && lastHashIndex !== -1) {
|
||||
triggerIndex = lastHashIndex;
|
||||
trigger = '#';
|
||||
} else if (lastPlusIndex !== -1) {
|
||||
triggerIndex = lastPlusIndex;
|
||||
trigger = '+';
|
||||
}
|
||||
|
||||
expect(trigger).toBe(null);
|
||||
expect(triggerIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it('should pick most recent trigger when multiple exist', () => {
|
||||
const textBeforeCursor = 'Text @home #urgent +proj';
|
||||
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||
const lastHashIndex = textBeforeCursor.lastIndexOf('#');
|
||||
const lastPlusIndex = textBeforeCursor.lastIndexOf('+');
|
||||
|
||||
let triggerIndex = -1;
|
||||
let trigger: '@' | '#' | '+' | null = null;
|
||||
|
||||
if (lastAtIndex >= lastHashIndex && lastAtIndex >= lastPlusIndex && lastAtIndex !== -1) {
|
||||
triggerIndex = lastAtIndex;
|
||||
trigger = '@';
|
||||
} else if (lastHashIndex >= lastPlusIndex && lastHashIndex !== -1) {
|
||||
triggerIndex = lastHashIndex;
|
||||
trigger = '#';
|
||||
} else if (lastPlusIndex !== -1) {
|
||||
triggerIndex = lastPlusIndex;
|
||||
trigger = '+';
|
||||
}
|
||||
|
||||
const queryAfterTrigger = triggerIndex !== -1 ? textBeforeCursor.slice(triggerIndex + 1) : '';
|
||||
|
||||
expect(trigger).toBe('+');
|
||||
expect(triggerIndex).toBe(19);
|
||||
expect(queryAfterTrigger).toBe('proj');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue