From 508bb1637bdfbae67425b2ad923469fa0dcffdfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=B6dler?= Date: Sat, 20 May 2023 13:19:59 +0200 Subject: [PATCH] Adds indexingService tests, refactors --- src/main.ts | 3 + src/services/indexingService.test.ts | 184 +++++++++++++++++++++++ src/services/indexingService.ts | 8 +- src/services/tokenizationService.test.ts | 8 +- 4 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 src/services/indexingService.test.ts diff --git a/src/main.ts b/src/main.ts index 38c0537..19f1463 100644 --- a/src/main.ts +++ b/src/main.ts @@ -107,6 +107,9 @@ export default class CrossbowPlugin extends Plugin { private onFileRename = (file: TAbstractFile, oldPath: string): void => { this.loggingService.debugLog(`⚡File renamed. '${file.name}'`); this.indexingService.clearCacheFromFile(oldPath); + + // TODO: Verify if we could just use: this.indexingService.indexFile(file as TFile);$ + // Could be problematic since file is TAbstractFile this.app.metadataCache.trigger('changed', file as TFile, ''); // Trigger metadata change to update cache }; diff --git a/src/services/indexingService.test.ts b/src/services/indexingService.test.ts new file mode 100644 index 0000000..28c433c --- /dev/null +++ b/src/services/indexingService.test.ts @@ -0,0 +1,184 @@ +// Copyright (C) 2023 - shoedler - github.com/shoedler +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import { CachedMetadata, FileStats, HeadingCache, TFile, TFolder, TagCache, Vault } from 'obsidian'; +import { CrossbowIndexingService } from './indexingService'; +import { CrossbowLoggingService } from './loggingService'; +import { CrossbowSettingsService, DEFAULT_SETTINGS } from './settingsService'; + +const proto = CrossbowIndexingService.prototype; + +describe('indexingService', () => { + describe(`${proto.indexFile.name}()`, () => { + it('should generate cache entries for headings, tags and one for the file itself', () => { + const fileName = 'testFile'; + const file = createFileMock(fileName); + const metadata = createMetadataCacheMock(); + const service = createServiceMock(); + + service.indexFile(file, metadata); + const cache = service.getCache(); + + // Key count + expect(Object.keys(cache)).toHaveLength(5); + expect(Object.values(cache).filter((value) => value.type === 'File')).toHaveLength(1); + expect(Object.values(cache).filter((value) => value.type === 'Heading')).toHaveLength(3); + expect(Object.values(cache).filter((value) => value.type === 'Tag')).toHaveLength(1); + + expect(Object.keys(getSourceLookup(service))).toHaveLength(1); + + // Should contain file cache entry + expect(cache[fileName]).toBeDefined(); + expect(cache[fileName].type).toBe('File'); + + if (metadata.headings === undefined) { + throw new Error('Metadata headings are undefined'); + } + + // Should contain heading cache entries + metadata.headings.forEach((headingCache) => { + expect(cache[headingCache.heading]).toBeDefined(); + expect(cache[headingCache.heading].type).toBe('Heading'); + expect(cache[headingCache.heading].file).toBe(file); + expect(cache[headingCache.heading].text).toBe(headingCache.heading); + }); + + if (metadata.tags === undefined) { + throw new Error('Metadata tags are undefined'); + } + + // Should contain tag cache entries + metadata.tags.forEach((tagCache) => { + expect(cache[tagCache.tag]).toBeDefined(); + expect(cache[tagCache.tag].type).toBe('Tag'); + expect(cache[tagCache.tag].file).toBe(file); + expect(cache[tagCache.tag].text).toBe(tagCache.tag); + }); + }); + + it('should not duplicate cache entries when file cache is provided', () => { + const file = createFileMock('testFile'); + const metadata = createMetadataCacheMock(); + const service = createServiceMock(); + + service.indexFile(file, metadata); + expect(Object.keys(service.getCache())).toHaveLength(5); + + service.indexFile(file, metadata); + expect(Object.keys(service.getCache())).toHaveLength(5); + }); + }); + + describe(`${proto.clearCacheFromFile.name}()`, () => { + it('should clear cache entries (headings, tags and the file)', () => { + const file: TFile = createFileMock('testFile'); + const metadata = createMetadataCacheMock(); + const service = createServiceMock(); + + service.indexFile(file, metadata); + expect(Object.keys(service.getCache())).toHaveLength(5); + expect(Object.keys(getSourceLookup(service))).toHaveLength(1); + + service.clearCacheFromFile(file); + expect(Object.keys(service.getCache())).toHaveLength(0); + expect(Object.keys(getSourceLookup(service))).toHaveLength(0); + }); + + it("shouldn't clear cache entries (headings, tags and the file) of other files", () => { + const file = createFileMock('testFile'); + const metadata = createMetadataCacheMock(); + const service = createServiceMock(); + + service.indexFile(file, metadata); + expect(Object.keys(service.getCache())).toHaveLength(5); + expect(Object.keys(getSourceLookup(service))).toHaveLength(1); + + service.clearCacheFromFile(file); + expect(Object.keys(service.getCache())).toHaveLength(0); + expect(Object.keys(getSourceLookup(service))).toHaveLength(0); + }); + }); + + describe(`${proto.clearCache.name}()`, () => { + it('should clear cache (and sourceFileLookup)', () => { + const fileName1 = 'testFile'; + const file1 = createFileMock(fileName1); + const metadata1 = createMetadataCacheMock(); + + const fileName2 = 'testFile2'; + const file2 = createFileMock(fileName2); + const metadata2 = createMetadataCacheMock(); + + const service = createServiceMock(); + + service.indexFile(file1, metadata1); + service.indexFile(file2, metadata2); + + expect(Object.keys(service.getCache())).toHaveLength(10); + expect(Object.keys(getSourceLookup(service))).toHaveLength(2); + + service.clearCache(); + expect(Object.keys(service.getCache())).toHaveLength(5); + expect(Object.keys(getSourceLookup(service))).toHaveLength(1); + }); + }); +}); + +const settingsServiceMock = new CrossbowSettingsService((settings) => Promise.resolve()); +const loggingService = new CrossbowLoggingService(settingsServiceMock); + +settingsServiceMock.saveSettings(DEFAULT_SETTINGS); + +const createServiceMock = (): CrossbowIndexingService => + new CrossbowIndexingService(settingsServiceMock, loggingService); + +const getSourceLookup = (service: CrossbowIndexingService): object => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (service as any).sourceFileLookup as object; +}; + +const createFileMock = (fileName: string): TFile => ({ + basename: fileName, + name: `${fileName}Name`, + path: `./${fileName}`, + extension: 'md', + stat: null as unknown as FileStats, + vault: null as unknown as Vault, + parent: null as unknown as TFolder, +}); + +const createMetadataCacheMock = (headingsCount = 3, tagsCount = 1): CachedMetadata => { + const headings: HeadingCache[] = []; + for (let index = 0; index < headingsCount; index++) { + headings.push({ + level: index + 1, + heading: `Heading ${index + 1}`, + position: { + start: { line: index, col: 0, offset: 0 }, + end: { line: index, col: 8 + index.toString().length, offset: 0 }, + }, + }); + } + + const tags: TagCache[] = []; + for (let index = 0; index < tagsCount; index++) { + tags.push({ + tag: `Tag ${index + 1}`, + position: { + start: { line: index + headingsCount, col: 0, offset: 0 }, + end: { line: index + headingsCount, col: 5 + index.toString().length, offset: 0 }, + }, + }); + } + + return { headings, tags }; +}; diff --git a/src/services/indexingService.ts b/src/services/indexingService.ts index 38ac260..88e78fe 100644 --- a/src/services/indexingService.ts +++ b/src/services/indexingService.ts @@ -29,8 +29,8 @@ export interface CacheMatch extends CacheEntry { } export class CrossbowIndexingService { - private readonly crossbowCache: CacheEntryLookup = {}; - private readonly sourceFileLookup: SourceLookup = {}; + private crossbowCache: CacheEntryLookup = {}; + private sourceFileLookup: SourceLookup = {}; public constructor( private readonly settingsService: CrossbowSettingsService, @@ -109,7 +109,7 @@ export class CrossbowIndexingService { } public clearCache(): void { - Object.assign(this.crossbowCache, {}); - Object.assign(this.sourceFileLookup, {}); + this.crossbowCache = {}; + this.sourceFileLookup = {}; } } diff --git a/src/services/tokenizationService.test.ts b/src/services/tokenizationService.test.ts index 2f7fe13..e8da2e3 100644 --- a/src/services/tokenizationService.test.ts +++ b/src/services/tokenizationService.test.ts @@ -15,8 +15,10 @@ import { Editor } from 'obsidian'; import { CrossbowTokenizationService, WordLookup } from './tokenizationService'; +const proto = CrossbowTokenizationService.prototype; + describe('tokenizationService', () => { - describe('redactText()', () => { + describe(`${CrossbowTokenizationService.redactText.name}()`, () => { it('should redact code blocks (```) from a string, leaving spaces in its place', () => { const input___ = 'This is a ```\ncode block\n``` string'; const expected = 'This is a \n \n string'; @@ -90,7 +92,7 @@ describe('tokenizationService', () => { }); }); - describe('cleanWord()', () => { + describe(`${CrossbowTokenizationService.cleanWord.name}()`, () => { it('should remove anything but alphanumeric chars from a word', () => { const input = 'Word$¨\'^!"*ç"*ç%&/()=?`*'; const expected = 'Word'; @@ -99,7 +101,7 @@ describe('tokenizationService', () => { }); }); - describe('getWordLookupFromEditor()', () => { + describe(`${proto.getWordLookupFromEditor.name}()`, () => { const mockEditor = (value: string) => { return { value,