aaronsb_obsidian-mcp-plugin/tests/dataview-integration.test.ts
Aaron Bockelie 1fdcbc3a8b fix(dataview): recover rows for implicit LIST GROUP BY; label whitespace group keys
Two findings from testing 0.11.37 live against Dataview 0.5.68 in the app —
neither reachable through the pre-existing mock fixtures, both real:

1. Implicit `LIST ... GROUP BY` returned bare group keys, no rows. Dataview's
   programmatic query() API drops grouped rows when nothing in the query
   references `rows` (unlike rendered markdown), so `LIST FROM "x" GROUP BY
   file.folder` came back as just the folder names. normalizeListGroupByQuery
   injects a default `rows.file.link` output expression for exactly that shape
   — a LIST with GROUP BY and no explicit output expression — so proper
   {key, rows} groups come back. Queries that already name an output
   expression, and non-LIST / non-grouped queries, pass through untouched; the
   user-facing echoed query stays the original input.

2. `TASK ... GROUP BY status` groups incomplete tasks under a space-character
   key, which rendered as an empty bold header (`**** (n)`). groupKeyLabel now
   treats whitespace-only keys the same as empty → `(no group)`.

The test mock is made faithful to the live behavior: a grouped LIST that
references `rows` returns list-pair wrappers, one that doesn't collapses to bare
keys — so the injection is actually observable rather than passing vacuously.
Adds normalization unit tests, an end-to-end implicit-grouped-LIST recovery
test, and a whitespace-key label test. build + lint + test green, 355/355.
2026-07-03 21:47:21 -05:00

712 lines
No EOL
26 KiB
TypeScript

import { DataviewTool, isDataviewToolAvailable, normalizeListGroupByQuery } from '../src/tools/dataview-tool';
import { PluginDetector } from '../src/utils/plugin-detector';
import { formatResponse } from '../src/formatters';
/**
* Mock Obsidian App for testing
*/
class MockApp {
plugins = {
enabledPlugins: new Set<string>(),
manifests: {} as Record<string, any>,
plugins: {} as Record<string, any>
};
constructor(dataviewEnabled = false, dataviewApiReady = false) {
if (dataviewEnabled) {
this.plugins.enabledPlugins.add('dataview');
this.plugins.manifests['dataview'] = { version: '0.5.64' };
if (dataviewApiReady) {
this.plugins.plugins['dataview'] = {
manifest: { version: '0.5.64' },
api: new MockDataviewAPI()
};
} else {
this.plugins.plugins['dataview'] = {
manifest: { version: '0.5.64' },
api: null
};
}
}
}
}
/**
* Mock Dataview API for testing
*/
class MockDataviewAPI {
query(query: string) {
// Mock DQL query execution. Dataview's real query() resolves to a Result
// monad — { successful, value, error } — where value.values is a PLAIN
// array (not a wrapped DataArray). The previous mock used the wrong flat +
// wrapped shape, which is why CI stayed green while real Dataview broke (#216).
// GROUP BY: Dataview nests rows under group wrappers, and the collections
// are DataArrays (`.array()`), not plain arrays. Mocked faithfully so the
// producer's group-unwrap + flatten is actually exercised (#220).
if (query.includes('GROUP BY') && query.includes('TASK')) {
return {
successful: true,
value: {
type: 'task',
values: {
array: () => [
{ key: 'open', rows: { array: () => [
{ text: 'task one', completed: false, path: 'Note1.md' },
{ text: 'task two', completed: true, path: 'Note2.md' }
] } },
{ key: null, rows: { array: () => [
{ text: 'ungrouped task', completed: false, path: 'Note3.md' }
] } }
]
}
}
};
}
if (query.includes('GROUP BY')) {
// Faithful to real Dataview: a grouped LIST that references `rows` returns
// list-pair group wrappers; one that does NOT collapses to bare group keys
// (the rows are dropped from the query() payload). This is what makes the
// normalizeListGroupByQuery injection observable (#220 live follow-up).
if (!query.includes('rows')) {
return {
successful: true,
value: {
type: 'list',
values: ['groupA', 'groupB']
}
};
}
return {
successful: true,
value: {
type: 'list',
values: {
array: () => [
{ $widget: 'dataview:list-pair', key: 'groupA', value: { array: () => ['Note 1', 'Note 2'] } },
{ $widget: 'dataview:list-pair', key: null, value: { array: () => ['Note 3'] } }
]
}
}
};
}
if (query.includes('LIST')) {
return {
successful: true,
value: {
type: 'list',
values: ['Note 1', 'Note 2', 'Note 3']
}
};
}
if (query.includes('TABLE') && query.includes('mtime')) {
// Rich TABLE: rows are DataArrays of cells; cells are a Link object and a
// Luxon-style DateTime (toISO), not primitives (#220 cell rendering).
return {
successful: true,
value: {
type: 'table',
headers: ['File', 'file.mtime'],
values: [
{ array: () => [
{ path: 'Note1.md', display: 'Note1', type: 'file' },
{ toISO: () => '2026-01-02T03:04:05.000Z' }
] },
{ array: () => [
{ path: 'Note2.md' },
{ toISO: () => '2026-01-03T00:00:00.000Z' }
] }
]
}
};
}
if (query.includes('TABLE')) {
return {
successful: true,
value: {
type: 'table',
headers: ['Name', 'Created', 'Tags'],
values: [
['Note 1', '2024-01-01', '#tag1'],
['Note 2', '2024-01-02', '#tag2']
]
}
};
}
throw new Error('Invalid query');
}
pages(source?: string) {
const mockPage1 = {
file: {
path: 'Note1.md',
name: 'Note1.md',
size: 1024,
ctime: new Date('2024-01-01'),
mtime: new Date('2024-01-02'),
tags: { array: () => ['#tag1', '#tag2'] },
outlinks: { array: () => [] }
},
aliases: { array: () => [] },
customField: 'value1',
rating: 5
};
const mockPage2 = {
file: {
path: 'Note2.md',
name: 'Note2.md',
size: 2048,
ctime: new Date('2024-01-03'),
mtime: new Date('2024-01-04'),
tags: { array: () => ['#tag3'] },
outlinks: { array: () => [] }
},
aliases: { array: () => ['Alias1'] },
customField: 'value2',
rating: 3
};
return {
length: 2,
array: () => [mockPage1, mockPage2]
};
}
page(path: string) {
if (path === 'Note1.md') {
return {
file: {
path: 'Note1.md',
name: 'Note1.md',
basename: 'Note1',
extension: 'md',
size: 1024,
ctime: new Date('2024-01-01'),
mtime: new Date('2024-01-02'),
tags: { array: () => ['#tag1', '#tag2'] },
outlinks: { array: () => ['Note2.md'] },
inlinks: { array: () => [] },
tasks: { array: () => [] },
lists: { array: () => [] }
},
aliases: { array: () => [] },
customField: 'test value',
rating: 5,
priority: 'high'
};
}
return null;
}
}
/**
* Mock ObsidianAPI for testing
*/
class MockObsidianAPI {
constructor(private app: MockApp) {}
getApp() {
return this.app;
}
}
describe('Dataview Integration', () => {
describe('PluginDetector', () => {
test('should detect when Dataview is not installed', () => {
const app = new MockApp(false);
const detector = new PluginDetector(app as any);
expect(detector.isPluginInstalled('dataview')).toBe(false);
expect(detector.isPluginEnabled('dataview')).toBe(false);
expect(detector.isDataviewAvailable()).toBe(false);
expect(detector.isDataviewAPIReady()).toBe(false);
});
test('should detect when Dataview is installed but not enabled', () => {
const app = new MockApp(false);
app.plugins.manifests['dataview'] = { version: '0.5.64' };
const detector = new PluginDetector(app as any);
expect(detector.isPluginInstalled('dataview')).toBe(true);
expect(detector.isPluginEnabled('dataview')).toBe(false);
expect(detector.isDataviewAvailable()).toBe(false);
expect(detector.isDataviewAPIReady()).toBe(false);
});
test('should detect when Dataview is enabled but API not ready', () => {
const app = new MockApp(true, false);
const detector = new PluginDetector(app as any);
expect(detector.isPluginInstalled('dataview')).toBe(true);
expect(detector.isPluginEnabled('dataview')).toBe(true);
expect(detector.isDataviewAvailable()).toBe(true);
expect(detector.isDataviewAPIReady()).toBe(false);
});
test('should detect when Dataview is fully available', () => {
const app = new MockApp(true, true);
const detector = new PluginDetector(app as any);
expect(detector.isPluginInstalled('dataview')).toBe(true);
expect(detector.isPluginEnabled('dataview')).toBe(true);
expect(detector.isDataviewAvailable()).toBe(true);
expect(detector.isDataviewAPIReady()).toBe(true);
const status = detector.getDataviewStatus();
expect(status.installed).toBe(true);
expect(status.enabled).toBe(true);
expect(status.apiReady).toBe(true);
expect(status.version).toBe('0.5.64');
});
});
describe('DataviewTool', () => {
test('should report unavailable when Dataview is not ready', () => {
const app = new MockApp(false);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
expect(tool.isAvailable()).toBe(false);
const status = tool.getStatus();
expect(status.installed).toBe(false);
expect(status.enabled).toBe(false);
expect(status.apiReady).toBe(false);
});
test('should report available when Dataview is ready', () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
expect(tool.isAvailable()).toBe(true);
const status = tool.getStatus();
expect(status.installed).toBe(true);
expect(status.enabled).toBe(true);
expect(status.apiReady).toBe(true);
expect(status.version).toBe('0.5.64');
});
test('should execute LIST queries', async () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const result = await tool.executeQuery('LIST FROM #tag') as any;
expect(result.success).toBe(true);
expect(result.query).toBe('LIST FROM #tag');
expect(result.format).toBe('dql');
expect(result.result.type).toBe('list');
expect(result.result.values).toEqual(['Note 1', 'Note 2', 'Note 3']);
});
test('should execute TABLE queries', async () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const result = await tool.executeQuery('TABLE name, created FROM #tag') as any;
expect(result.success).toBe(true);
expect(result.result.type).toBe('table');
expect(result.result.headers).toEqual(['Name', 'Created', 'Tags']);
expect(result.result.values).toHaveLength(2);
});
test('should list pages with metadata', async () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const result = await tool.listPages() as any;
expect(result.success).toBe(true);
expect(result.source).toBe('all');
expect(result.count).toBe(2);
expect(result.pages).toHaveLength(2);
expect(result.pages[0].path).toBe('Note1.md');
expect(result.pages[0].customField).toBe('value1');
expect(result.pages[0].rating).toBe(5);
});
test('should get page metadata', async () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const result = await tool.getPageMetadata('Note1.md') as any;
expect(result.success).toBe(true);
expect(result.path).toBe('Note1.md');
expect(result.metadata.file.path).toBe('Note1.md');
expect(result.metadata.custom.customField).toBe('test value');
expect(result.metadata.custom.rating).toBe(5);
expect(result.metadata.custom.priority).toBe('high');
});
test('should serialize Luxon DateTime values from listPages without crashing', async () => {
// Regression for #123 bug 2: Dataview emits Luxon DateTime objects
// (toISO() returns string|null) and not native Date.toISOString().
const luxonDate = (iso: string) => ({ toISO: () => iso });
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const dvApi = (app.plugins.plugins['dataview'] as any).api as MockDataviewAPI;
dvApi.pages = (() => ({
length: 1,
array: () => [{
file: {
path: 'Luxon.md',
name: 'Luxon.md',
size: 42,
ctime: luxonDate('2026-01-02T03:04:05.000Z'),
mtime: luxonDate('2026-01-02T03:04:06.000Z'),
tags: { array: () => [] },
outlinks: { array: () => [] }
},
aliases: { array: () => [] }
}]
})) as any;
const result = tool.listPages() as any;
expect(result.success).toBe(true);
expect(result.pages[0].created).toBe('2026-01-02T03:04:05.000Z');
expect(result.pages[0].modified).toBe('2026-01-02T03:04:06.000Z');
});
test('should serialize Luxon DateTime values from getPageMetadata without crashing', async () => {
// Regression for #123 bug 3
const luxonDate = (iso: string) => ({ toISO: () => iso });
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const dvApi = (app.plugins.plugins['dataview'] as any).api as MockDataviewAPI;
dvApi.page = (path: string) => path === 'Luxon.md' ? {
file: {
path: 'Luxon.md',
name: 'Luxon.md',
basename: 'Luxon',
extension: 'md',
size: 42,
ctime: luxonDate('2026-01-02T03:04:05.000Z'),
mtime: luxonDate('2026-01-02T03:04:06.000Z'),
tags: { array: () => [] },
outlinks: { array: () => [] },
inlinks: { array: () => [] },
tasks: { array: () => [] },
lists: { array: () => [] }
},
aliases: { array: () => [] }
} as any : null;
const result = tool.getPageMetadata('Luxon.md') as any;
expect(result.success).toBe(true);
expect(result.metadata.file.created).toBe('2026-01-02T03:04:05.000Z');
expect(result.metadata.file.modified).toBe('2026-01-02T03:04:06.000Z');
});
test('should propagate Dataview-internal query failures to outer envelope', async () => {
// Regression for #115 / #123 bug 1: Dataview returns
// {successful: false, error: "..."}
// when a query is malformed instead of throwing. The tool used to
// hard-code success:true regardless, hiding the real error.
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const dvApi = (app.plugins.plugins['dataview'] as any).api as MockDataviewAPI;
// A failed Dataview query resolves the Result monad with no `value`,
// only `{ successful: false, error }`.
dvApi.query = (() => ({
successful: false,
error: 'No field "nonexistent" on this page'
})) as any;
const result = await tool.executeQuery('TABLE nonexistent FROM ""') as any;
expect(result.success).toBe(false);
expect(result.error).toBe('No field "nonexistent" on this page');
});
test('should validate queries', async () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const validResult = await tool.validateQuery('LIST FROM #tag') as any;
expect(validResult.valid).toBe(true);
expect(validResult.queryType).toBe('LIST');
const invalidResult = await tool.validateQuery('INVALID QUERY') as any;
expect(invalidResult.valid).toBe(false);
expect(invalidResult.error).toContain('Query must start with one of');
});
});
// #216: the formatted (non-raw) output is what MCP clients see by default.
// These exercise the full producer → formatResponse() path that was broken,
// not just the raw envelope (which already worked).
describe('Formatted output (#216)', () => {
test('dataview.query LIST renders results, not "UNKNOWN"/"No results found"', async () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const queryResult = await tool.executeQuery('LIST FROM #tag');
const output = formatResponse('dataview', 'query', queryResult, false);
expect(output).toContain('Dataview: LIST');
expect(output).not.toContain('UNKNOWN');
expect(output).not.toContain('No results found');
expect(output).toContain('Note 1');
});
test('dataview.query TABLE renders its rows', async () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const queryResult = await tool.executeQuery('TABLE name FROM #tag');
const output = formatResponse('dataview', 'query', queryResult, false);
expect(output).toContain('Dataview: TABLE');
expect(output).not.toContain('No results found');
expect(output).toContain('Note 1');
});
test('dataview.status renders "available" when the plugin is ready', () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const output = formatResponse('dataview', 'status', tool.getStatus(), false);
expect(output).toContain('✓ Dataview plugin is available');
expect(output).not.toContain('✗ Dataview plugin is not available');
expect(output).toContain('0.5.64');
});
});
// #220: dataview.list / dataview.metadata non-raw output always rendered
// "No results found" because their {count,pages} / {metadata} producer shapes
// were force-cast into the {values} query formatter. These exercise the full
// producer → formatResponse() path with the real Dataview producer shapes.
describe('Formatted output (#220)', () => {
test('dataview.list renders page paths and count, not "No results found"', () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const output = formatResponse('dataview', 'list', tool.listPages('#tag'), false);
expect(output).toContain('Dataview: Pages');
expect(output).not.toContain('No results found');
expect(output).toContain('Count');
expect(output).toContain('Note1.md');
expect(output).toContain('Note2.md');
});
test('dataview.metadata renders page metadata and frontmatter, not "No results found"', () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const output = formatResponse('dataview', 'metadata', tool.getPageMetadata('Note1.md'), false);
expect(output).toContain('Dataview: Metadata');
expect(output).not.toContain('No results found');
expect(output).toContain('Note1.md');
// custom frontmatter fields surfaced
expect(output).toContain('priority');
expect(output).toContain('high');
});
test('dataview.list surfaces a producer failure instead of a false "No results found"', () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const dvApi = (app.plugins.plugins['dataview'] as any).api as MockDataviewAPI;
dvApi.pages = (() => { throw new Error('bad source'); }) as any;
const output = formatResponse('dataview', 'list', tool.listPages('nonsense'), false);
expect(output).toContain('Query failed');
expect(output).toContain('bad source');
expect(output).not.toContain('No results found');
});
test('dataview.metadata surfaces a page-not-found failure', () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
// page() returns null for an unknown path → producer returns {success:false, error}
const output = formatResponse('dataview', 'metadata', tool.getPageMetadata('Missing.md'), false);
expect(output).toContain('Dataview: Metadata');
expect(output).toContain('Missing.md');
expect(output).toContain('Page not found');
});
});
// #220 (folded in): GROUP BY queries nest rows under group wrappers; the
// list/task branches rendered the wrappers (or, for tasks, mangled them into
// empty tasks) instead of the grouped rows. Driven end-to-end through
// executeQuery → formatResponse with DataArray-wrapped fixtures.
describe('GROUP BY formatted output (#220)', () => {
test('LIST ... GROUP BY renders group keys and rows, not the list-pair wrapper', async () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const result = await tool.executeQuery('LIST rows.file.name FROM #tag GROUP BY group');
const output = formatResponse('dataview', 'query', result, false);
expect(output).toContain('Dataview: LIST');
expect(output).not.toContain('No results found');
expect(output).not.toContain('list-pair'); // wrapper not leaked
expect(output).not.toContain('$widget');
expect(output).toContain('groupA');
expect(output).toContain('Note 1');
expect(output).toContain('Note 2');
expect(output).toContain('(no group)'); // null group key
expect(output).toContain('Note 3');
});
test('TASK ... GROUP BY renders group keys and their tasks', async () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const result = await tool.executeQuery('TASK FROM #tag GROUP BY status');
const output = formatResponse('dataview', 'query', result, false);
expect(output).toContain('Dataview: TASK');
expect(output).not.toContain('No results found');
expect(output).toContain('open'); // group key
expect(output).toContain('task one');
expect(output).toContain('[x] task two'); // completed checkbox preserved
expect(output).toContain('(no group)');
expect(output).toContain('ungrouped task');
});
});
// #220 (folded in, from the maintainer's comment): TABLE cells that are
// Dataview Link / DateTime objects rendered as raw JSON / quoted ISO strings.
describe('TABLE cell rendering (#220)', () => {
test('Link cells collapse to display/path and dates render unquoted', async () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
const result = await tool.executeQuery('TABLE file.mtime FROM ""');
const output = formatResponse('dataview', 'query', result, false);
expect(output).toContain('Dataview: TABLE');
// Link → display (first row) / path (second row), not raw JSON
expect(output).not.toContain('{"path"');
expect(output).toContain('Note1'); // display
expect(output).toContain('Note2.md'); // path fallback
// DateTime → bare ISO, no surrounding quotes
expect(output).toContain('2026-01-02T03:04:05.000Z');
expect(output).not.toContain('"2026-01-02T03:04:05.000Z"');
});
});
// Live follow-up to #220 (found running 0.11.37 against Dataview 0.5.68):
// Dataview's query() API drops grouped rows for an implicit `LIST ... GROUP BY`
// (no `rows` reference), returning bare group keys. normalizeListGroupByQuery
// injects a default `rows.file.link` output so proper groups come back.
describe('implicit LIST ... GROUP BY row recovery', () => {
test('injects rows.file.link only for a grouped LIST with no output expression', () => {
// Implicit grouped LIST → augmented
expect(normalizeListGroupByQuery('LIST FROM "x" GROUP BY file.folder'))
.toBe('LIST rows.file.link FROM "x" GROUP BY file.folder');
expect(normalizeListGroupByQuery('LIST GROUP BY status'))
.toBe('LIST rows.file.link GROUP BY status');
expect(normalizeListGroupByQuery('list from "x" group by y'))
.toBe('LIST rows.file.link from "x" group by y');
// Already has an output expression → unchanged
expect(normalizeListGroupByQuery('LIST rows.file.name FROM "x" GROUP BY file.folder'))
.toBe('LIST rows.file.name FROM "x" GROUP BY file.folder');
// Not grouped → unchanged
expect(normalizeListGroupByQuery('LIST FROM "x"'))
.toBe('LIST FROM "x"');
// Not a LIST → unchanged
expect(normalizeListGroupByQuery('TABLE file.mtime FROM "x" GROUP BY file.folder'))
.toBe('TABLE file.mtime FROM "x" GROUP BY file.folder');
expect(normalizeListGroupByQuery('TASK FROM "x" GROUP BY status'))
.toBe('TASK FROM "x" GROUP BY status');
});
test('an implicit grouped LIST renders grouped rows end-to-end, not bare keys', async () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
// No `rows` in the user query; the faithful mock would return bare keys
// without the injection. With it, proper {key, rows} groups come back.
const result = await tool.executeQuery('LIST FROM #tag GROUP BY group');
const output = formatResponse('dataview', 'query', result, false);
expect(output).toContain('Dataview: LIST');
expect(output).toContain('groupA');
expect(output).toContain('Note 1'); // grouped row recovered
expect(output).toContain('Note 2');
expect(output).not.toContain('$widget');
});
});
// Live follow-up: `TASK ... GROUP BY status` groups incomplete tasks under a
// space-character key, which rendered as an empty bold header (`**** (n)`).
describe('whitespace group key label', () => {
test('a whitespace-only group key renders as (no group), not empty bold', () => {
const result = {
success: true,
query: 'TASK FROM "x" GROUP BY status',
format: 'dql',
result: {
type: 'task',
values: [
{ key: ' ', rows: [{ text: 'incomplete task', completed: false, path: 'Note1.md' }] }
]
},
type: 'task'
};
const output = formatResponse('dataview', 'query', result, false);
expect(output).toContain('(no group)');
expect(output).toContain('incomplete task');
expect(output).not.toContain('**** '); // no empty bold header
});
});
describe('Tool Availability Detection', () => {
test('should detect when Dataview tool is not available', () => {
const app = new MockApp(false);
const api = new MockObsidianAPI(app);
expect(isDataviewToolAvailable(api as any)).toBe(false);
});
test('should detect when Dataview tool is available', () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
expect(isDataviewToolAvailable(api as any)).toBe(true);
});
});
});