96 SUCCESS tests complet
This commit is contained in:
@@ -302,7 +302,11 @@ export class InMemoryRepository implements IBookmarksRepository {
|
||||
/**
|
||||
* Repository factory - automatically selects best adapter
|
||||
*/
|
||||
export function createRepository(): IBookmarksRepository {
|
||||
// Default to server bridge repository (backend-managed bookmarks)
|
||||
export type RepositoryFactory = () => IBookmarksRepository;
|
||||
|
||||
export function createRepository(factory?: RepositoryFactory): IBookmarksRepository {
|
||||
if (factory) {
|
||||
return factory();
|
||||
}
|
||||
return new ServerBridgeRepository('/api/vault');
|
||||
}
|
||||
|
||||
@@ -3,16 +3,25 @@
|
||||
*/
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { BookmarksService } from './bookmarks.service';
|
||||
import { InMemoryRepository } from './bookmarks.repository';
|
||||
import { createRepository, InMemoryRepository } from './bookmarks.repository';
|
||||
import type { BookmarksDoc, BookmarkGroup, BookmarkFile } from './types';
|
||||
|
||||
describe('BookmarksService', () => {
|
||||
let service: BookmarksService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [BookmarksService],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
BookmarksService,
|
||||
{
|
||||
provide: createRepository,
|
||||
useFactory: () => () => new InMemoryRepository(),
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(BookmarksService);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Bookmarks Service - State management with Signals
|
||||
*/
|
||||
|
||||
import { Injectable, signal, computed, effect } from '@angular/core';
|
||||
import { inject, Injectable, signal, computed, effect } from '@angular/core';
|
||||
import type {
|
||||
BookmarksDoc,
|
||||
BookmarkNode,
|
||||
|
||||
@@ -21,8 +21,17 @@ export function cloneNode(node: BookmarkNode): BookmarkNode {
|
||||
/**
|
||||
* Generate a unique ctime (milliseconds timestamp)
|
||||
*/
|
||||
let lastGeneratedCtime = 0;
|
||||
|
||||
export function generateCtime(): number {
|
||||
return Date.now();
|
||||
const now = Date.now();
|
||||
if (now <= lastGeneratedCtime) {
|
||||
lastGeneratedCtime += 1;
|
||||
return lastGeneratedCtime;
|
||||
}
|
||||
|
||||
lastGeneratedCtime = now;
|
||||
return lastGeneratedCtime;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,35 +1,6 @@
|
||||
import { detectQueryType, parseSearchQuery, queryToPredicate } from './search-parser';
|
||||
import { SearchContext } from './search-parser.types';
|
||||
|
||||
type TestFn = () => void;
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
const testResults: TestResult[] = [];
|
||||
|
||||
const runTest = (name: string, fn: TestFn): void => {
|
||||
try {
|
||||
fn();
|
||||
testResults.push({ name });
|
||||
} catch (error) {
|
||||
testResults.push({ name, error: error instanceof Error ? error : new Error(String(error)) });
|
||||
console.error(`[search-parser spec] ${name} failed`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const assert = (condition: boolean, message: string): void => {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const assertEqual = <T>(actual: T, expected: T, message: string): void => {
|
||||
assert(actual === expected, `${message} (expected ${expected}, received ${actual})`);
|
||||
};
|
||||
|
||||
const createContext = (overrides: Partial<SearchContext> = {}): SearchContext => ({
|
||||
filePath: 'notes/example.md',
|
||||
fileName: 'example',
|
||||
@@ -47,55 +18,55 @@ const createContext = (overrides: Partial<SearchContext> = {}): SearchContext =>
|
||||
...overrides
|
||||
});
|
||||
|
||||
runTest('parseSearchQuery marks empty query as empty', () => {
|
||||
const parsed = parseSearchQuery('');
|
||||
assert(parsed.isEmpty, 'Empty query should be flagged as empty');
|
||||
assertEqual(parsed.ast.type, 'group', 'Empty query AST should be an empty group');
|
||||
});
|
||||
describe('Search Parser', () => {
|
||||
it('parseSearchQuery marks empty query as empty', () => {
|
||||
const parsed = parseSearchQuery('');
|
||||
expect(parsed.isEmpty).toBeTrue();
|
||||
expect(parsed.ast.type).toBe('group');
|
||||
});
|
||||
|
||||
runTest('parseSearchQuery handles basic text queries', () => {
|
||||
const parsed = parseSearchQuery('hello world');
|
||||
assert(!parsed.isEmpty, 'Non-empty query should not be marked empty');
|
||||
assertEqual(parsed.ast.type, 'group', 'Text query AST should be a group');
|
||||
});
|
||||
it('parseSearchQuery handles basic text queries', () => {
|
||||
const parsed = parseSearchQuery('hello world');
|
||||
expect(parsed.isEmpty).toBeFalse();
|
||||
expect(parsed.ast.type).toBe('group');
|
||||
});
|
||||
|
||||
runTest('parseSearchQuery handles operators', () => {
|
||||
const parsed = parseSearchQuery('path:notes/ tag:#tag file:example');
|
||||
assert(!parsed.isEmpty, 'Operator query should parse successfully');
|
||||
});
|
||||
it('parseSearchQuery handles operators', () => {
|
||||
const parsed = parseSearchQuery('path:notes/ tag:#tag file:example');
|
||||
expect(parsed.isEmpty).toBeFalse();
|
||||
});
|
||||
|
||||
runTest('queryToPredicate matches simple text', () => {
|
||||
const predicate = queryToPredicate(parseSearchQuery('hello'));
|
||||
assert(predicate(createContext()), 'Predicate should match when text is present');
|
||||
});
|
||||
it('queryToPredicate matches simple text', () => {
|
||||
const predicate = queryToPredicate(parseSearchQuery('hello'));
|
||||
expect(predicate(createContext())).toBeTrue();
|
||||
});
|
||||
|
||||
runTest('queryToPredicate respects negation', () => {
|
||||
const predicate = queryToPredicate(parseSearchQuery('-deprecated'));
|
||||
assert(predicate(createContext()), 'Predicate should pass when term is absent');
|
||||
assert(!predicate(createContext({ content: 'deprecated feature' })), 'Predicate should fail when negated term exists');
|
||||
});
|
||||
it('queryToPredicate respects negation', () => {
|
||||
const predicate = queryToPredicate(parseSearchQuery('-deprecated'));
|
||||
expect(predicate(createContext())).toBeTrue();
|
||||
expect(predicate(createContext({ content: 'deprecated feature' }))).toBeFalse();
|
||||
});
|
||||
|
||||
runTest('queryToPredicate matches task scopes', () => {
|
||||
const todoPredicate = queryToPredicate(parseSearchQuery('task-todo:Call'));
|
||||
const donePredicate = queryToPredicate(parseSearchQuery('task-done:Review'));
|
||||
assert(todoPredicate(createContext()), 'task-todo should match incomplete tasks');
|
||||
assert(donePredicate(createContext()), 'task-done should match completed tasks');
|
||||
});
|
||||
it('queryToPredicate matches task scopes', () => {
|
||||
const todoPredicate = queryToPredicate(parseSearchQuery('task-todo:Call'));
|
||||
const donePredicate = queryToPredicate(parseSearchQuery('task-done:Review'));
|
||||
expect(todoPredicate(createContext())).toBeTrue();
|
||||
expect(donePredicate(createContext())).toBeTrue();
|
||||
});
|
||||
|
||||
runTest('detectQueryType recognises path prefix', () => {
|
||||
const info = detectQueryType('path:notes');
|
||||
assertEqual(info.type, 'path', 'Should recognise path prefix');
|
||||
assertEqual(info.value, 'notes', 'Should capture path value');
|
||||
});
|
||||
it('detectQueryType recognises path prefix', () => {
|
||||
const info = detectQueryType('path:notes');
|
||||
expect(info.type).toBe('path');
|
||||
expect(info.value).toBe('notes');
|
||||
});
|
||||
|
||||
runTest('detectQueryType recognises property bracket', () => {
|
||||
const info = detectQueryType('[status:');
|
||||
assertEqual(info.type, 'property', 'Should recognise property prefix');
|
||||
});
|
||||
it('detectQueryType recognises property bracket', () => {
|
||||
const info = detectQueryType('[status:');
|
||||
expect(info.type).toBe('property');
|
||||
});
|
||||
|
||||
runTest('detectQueryType falls back to general type', () => {
|
||||
const info = detectQueryType('plain text');
|
||||
assertEqual(info.type, 'general', 'General text should return general type');
|
||||
it('detectQueryType falls back to general type', () => {
|
||||
const info = detectQueryType('plain text');
|
||||
expect(info.type).toBe('general');
|
||||
});
|
||||
});
|
||||
|
||||
export { testResults };
|
||||
|
||||
@@ -1,127 +1,70 @@
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert/strict';
|
||||
import { DownloadService } from './download.service.js';
|
||||
import { DownloadService } from './download.service';
|
||||
|
||||
const makeFakeDocument = () => {
|
||||
const appendCalls: unknown[] = [];
|
||||
const removeCalls: unknown[] = [];
|
||||
const anchor = {
|
||||
href: '',
|
||||
download: '',
|
||||
rel: '',
|
||||
style: {} as Record<string, unknown>,
|
||||
clickCount: 0,
|
||||
click() {
|
||||
this.clickCount += 1;
|
||||
},
|
||||
};
|
||||
describe('DownloadService', () => {
|
||||
let createElementSpy: jasmine.Spy;
|
||||
let appendSpy: jasmine.Spy;
|
||||
let removeSpy: jasmine.Spy;
|
||||
let createObjectURLSpy: jasmine.Spy;
|
||||
let revokeObjectURLSpy: jasmine.Spy;
|
||||
let capturedBlob: Blob | null;
|
||||
const mockUrl = 'blob:mock';
|
||||
|
||||
const documentMock = {
|
||||
createElement(tag: string) {
|
||||
if (tag !== 'a') {
|
||||
throw new Error(`Unexpected element request: ${tag}`);
|
||||
beforeEach(() => {
|
||||
capturedBlob = null;
|
||||
createElementSpy = spyOn(document, 'createElement').and.callThrough();
|
||||
appendSpy = spyOn(document.body, 'appendChild').and.callThrough();
|
||||
removeSpy = spyOn(document.body, 'removeChild').and.callThrough();
|
||||
createObjectURLSpy = spyOn(URL, 'createObjectURL').and.callFake((blob: Blob | MediaSource) => {
|
||||
if (blob instanceof Blob) {
|
||||
capturedBlob = blob;
|
||||
}
|
||||
return anchor;
|
||||
},
|
||||
body: {
|
||||
appendChild(node: unknown) {
|
||||
appendCalls.push(node);
|
||||
},
|
||||
removeChild(node: unknown) {
|
||||
removeCalls.push(node);
|
||||
},
|
||||
},
|
||||
} as unknown as Document;
|
||||
return mockUrl;
|
||||
});
|
||||
revokeObjectURLSpy = spyOn(URL, 'revokeObjectURL');
|
||||
});
|
||||
|
||||
return { documentMock, anchor, appendCalls, removeCalls };
|
||||
};
|
||||
afterEach(() => {
|
||||
createElementSpy.and.callThrough();
|
||||
createElementSpy.calls.reset();
|
||||
appendSpy.calls.reset();
|
||||
removeSpy.calls.reset();
|
||||
createObjectURLSpy.calls.reset();
|
||||
revokeObjectURLSpy.calls.reset();
|
||||
});
|
||||
|
||||
test('DownloadService downloads normalized UTF-8 content', async () => {
|
||||
const service = new DownloadService();
|
||||
const originalDocument = globalThis.document;
|
||||
const NativeURL = globalThis.URL;
|
||||
it('downloads normalized UTF-8 content', async () => {
|
||||
const service = new DownloadService();
|
||||
const anchor = document.createElement('a') as HTMLAnchorElement;
|
||||
const clickSpy = spyOn(anchor, 'click');
|
||||
|
||||
const { documentMock, anchor, appendCalls, removeCalls } = makeFakeDocument();
|
||||
let lastBlob: Blob | null = null;
|
||||
const revoked: string[] = [];
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
createElementSpy.and.callFake((tag: string) => {
|
||||
if (tag === 'a') {
|
||||
return anchor;
|
||||
}
|
||||
return originalCreateElement(tag);
|
||||
});
|
||||
|
||||
const urlMock = Object.assign(
|
||||
function MockURL(url: string | URL, base?: string | URL) {
|
||||
return new NativeURL(url as string, base as string);
|
||||
},
|
||||
{
|
||||
prototype: NativeURL.prototype,
|
||||
canParse: () => true,
|
||||
parse: (url: string | URL, base?: string | URL) => new NativeURL(url as string, base as string),
|
||||
createObjectURL(blob: Blob) {
|
||||
lastBlob = blob;
|
||||
return 'blob:test-url';
|
||||
},
|
||||
revokeObjectURL(url: string) {
|
||||
revoked.push(url);
|
||||
},
|
||||
}
|
||||
) as unknown as typeof URL;
|
||||
|
||||
globalThis.document = documentMock;
|
||||
globalThis.URL = urlMock;
|
||||
|
||||
try {
|
||||
service.downloadText('line1\r\nline2', 'note.md');
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
|
||||
assert.equal(anchor.download, 'note.md');
|
||||
assert.equal(anchor.rel, 'noopener');
|
||||
assert.equal(anchor.clickCount, 1);
|
||||
assert.equal(appendCalls.length, 1);
|
||||
assert.equal(removeCalls.length, 1);
|
||||
assert.equal(revoked.length, 1);
|
||||
assert.ok(lastBlob, 'Blob should be created');
|
||||
const blobContent = await lastBlob!.text();
|
||||
assert.equal(blobContent, 'line1\nline2');
|
||||
} finally {
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.URL = NativeURL;
|
||||
}
|
||||
});
|
||||
expect(anchor.download).toBe('note.md');
|
||||
expect(anchor.rel).toBe('noopener');
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
expect(appendSpy.calls.mostRecent().args[0]).toBe(anchor);
|
||||
expect(removeSpy.calls.mostRecent().args[0]).toBe(anchor);
|
||||
expect(createObjectURLSpy.calls.count()).toBe(1);
|
||||
expect(capturedBlob).toBeTruthy();
|
||||
expect(await capturedBlob!.text()).toBe('line1\nline2');
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith(mockUrl);
|
||||
});
|
||||
|
||||
test('DownloadService warns when content is empty', () => {
|
||||
const service = new DownloadService();
|
||||
const originalDocument = globalThis.document;
|
||||
const NativeURL = globalThis.URL;
|
||||
const originalWarn = console.warn;
|
||||
it('warns on empty content', () => {
|
||||
const service = new DownloadService();
|
||||
const warnSpy = spyOn(console, 'warn');
|
||||
|
||||
const { documentMock } = makeFakeDocument();
|
||||
const urlMock = Object.assign(
|
||||
function MockURL(url: string | URL, base?: string | URL) {
|
||||
return new NativeURL(url as string, base as string);
|
||||
},
|
||||
{
|
||||
prototype: NativeURL.prototype,
|
||||
canParse: () => true,
|
||||
parse: (url: string | URL, base?: string | URL) => new NativeURL(url as string, base as string),
|
||||
createObjectURL() {
|
||||
return 'blob:noop';
|
||||
},
|
||||
revokeObjectURL() {
|
||||
// noop
|
||||
},
|
||||
}
|
||||
) as unknown as typeof URL;
|
||||
|
||||
globalThis.document = documentMock;
|
||||
globalThis.URL = urlMock;
|
||||
|
||||
let warned = false;
|
||||
console.warn = () => {
|
||||
warned = true;
|
||||
};
|
||||
|
||||
try {
|
||||
service.downloadText('', 'empty.md');
|
||||
assert.equal(warned, true);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.URL = NativeURL;
|
||||
}
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('[DownloadService] Empty content provided for download.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,60 +1,69 @@
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert/strict';
|
||||
import { firstValueFrom, skip, take } from 'rxjs';
|
||||
import { MarkdownViewerService } from './markdown-viewer.service.js';
|
||||
import type { Note } from '../../types.js';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { firstValueFrom, take } from 'rxjs';
|
||||
import { MarkdownViewerService } from './markdown-viewer.service';
|
||||
import type { Note } from '../../types';
|
||||
describe('MarkdownViewerService', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideZonelessChangeDetection(), MarkdownViewerService],
|
||||
});
|
||||
});
|
||||
|
||||
test('MarkdownViewerService exposes current note streams', async () => {
|
||||
const service = new MarkdownViewerService();
|
||||
it('exposes current note streams', async () => {
|
||||
const service = TestBed.inject(MarkdownViewerService);
|
||||
|
||||
const note: Note = {
|
||||
id: 'note-1',
|
||||
title: 'Test note',
|
||||
content: 'rendered content',
|
||||
rawContent: 'raw markdown',
|
||||
tags: [],
|
||||
frontmatter: {},
|
||||
backlinks: [],
|
||||
mtime: Date.now(),
|
||||
fileName: 'note-1.md',
|
||||
filePath: '/note-1.md',
|
||||
originalPath: 'note-1',
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
};
|
||||
const note: Note = {
|
||||
id: 'note-1',
|
||||
title: 'Test note',
|
||||
content: 'rendered content',
|
||||
rawContent: 'raw markdown',
|
||||
tags: [],
|
||||
frontmatter: {},
|
||||
backlinks: [],
|
||||
mtime: Date.now(),
|
||||
fileName: 'note-1.md',
|
||||
filePath: '/note-1.md',
|
||||
originalPath: 'note-1',
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
};
|
||||
|
||||
service.setCurrentNote(note);
|
||||
service.setCurrentNote(note);
|
||||
|
||||
const observedNote = await firstValueFrom(service.getCurrentNote$().pipe(skip(1), take(1)));
|
||||
const observedRaw = await firstValueFrom(service.getCurrentRaw$().pipe(skip(1), take(1)));
|
||||
const observedFilename = await firstValueFrom(service.getCurrentFilename$().pipe(skip(1), take(1)));
|
||||
const observedNote = await firstValueFrom(service.getCurrentNote$().pipe(take(1)));
|
||||
const observedRaw = await firstValueFrom(service.getCurrentRaw$().pipe(take(1)));
|
||||
const observedFilename = await firstValueFrom(service.getCurrentFilename$().pipe(take(1)));
|
||||
|
||||
assert.equal(observedNote?.id, note.id);
|
||||
assert.equal(observedRaw, note.rawContent);
|
||||
assert.equal(observedFilename, note.fileName);
|
||||
});
|
||||
expect(observedNote?.id).toBe(note.id);
|
||||
expect(observedRaw).toBe(note.rawContent);
|
||||
expect(observedFilename).toBe(note.fileName);
|
||||
});
|
||||
|
||||
test('MarkdownViewerService falls back to content when rawContent missing', async () => {
|
||||
const service = new MarkdownViewerService();
|
||||
it('falls back to content when rawContent missing', async () => {
|
||||
const service = TestBed.inject(MarkdownViewerService);
|
||||
|
||||
const note: Note = {
|
||||
id: 'note-2',
|
||||
title: 'Another note',
|
||||
content: 'fallback content',
|
||||
rawContent: undefined as unknown as string,
|
||||
tags: [],
|
||||
frontmatter: {},
|
||||
backlinks: [],
|
||||
mtime: Date.now(),
|
||||
fileName: 'note-2.md',
|
||||
filePath: '/note-2.md',
|
||||
originalPath: 'note-2',
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
};
|
||||
const note: Note = {
|
||||
id: 'note-2',
|
||||
title: 'Another note',
|
||||
content: 'fallback content',
|
||||
// Force undefined through typing to simulate missing rawContent
|
||||
rawContent: undefined as unknown as string,
|
||||
tags: [],
|
||||
frontmatter: {},
|
||||
backlinks: [],
|
||||
mtime: Date.now(),
|
||||
fileName: 'note-2.md',
|
||||
filePath: '/note-2.md',
|
||||
originalPath: 'note-2',
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
};
|
||||
|
||||
service.setCurrentNote(note);
|
||||
service.setCurrentNote(note);
|
||||
|
||||
const observedRaw = await firstValueFrom(service.getCurrentRaw$().pipe(skip(1), take(1)));
|
||||
assert.equal(observedRaw, note.content);
|
||||
const observedRaw = await firstValueFrom(service.getCurrentRaw$().pipe(take(1)));
|
||||
expect(observedRaw).toBe(note.content);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user