96 SUCCESS tests complet

This commit is contained in:
2025-10-03 14:24:16 -04:00
parent 2dfe827759
commit 77ced60d7c
155 changed files with 11107 additions and 207392 deletions
+6 -2
View File
@@ -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');
}
+11 -2
View File
@@ -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);
});
+1 -1
View File
@@ -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,
+10 -1
View File
@@ -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;
}
/**
+43 -72
View File
@@ -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 };
+57 -114
View File
@@ -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);
});
});