From 80adf4aff77cb75902e2048d9eb943074228b578 Mon Sep 17 00:00:00 2001 From: Moritz Jung Date: Fri, 3 Jul 2026 11:38:15 +0200 Subject: [PATCH] more tests --- package.json | 4 +- .../obsidian/src/settings/PropertyMapping.ts | 2 +- tests/api-model.test.ts | 51 ++++++++ tests/media-db-file-helper.test.ts | 56 +++++++++ tests/media-type-models.test.ts | 76 ++++++++++++ tests/placeholder.test.ts | 5 - tests/property-mapping.test.ts | 116 ++++++++++++++++++ tests/result.test.ts | 40 ++++++ tests/setup.ts | 40 +++++- tests/utils.test.ts | 56 +++++++++ 10 files changed, 436 insertions(+), 10 deletions(-) create mode 100644 tests/api-model.test.ts create mode 100644 tests/media-db-file-helper.test.ts create mode 100644 tests/media-type-models.test.ts delete mode 100644 tests/placeholder.test.ts create mode 100644 tests/property-mapping.test.ts create mode 100644 tests/result.test.ts create mode 100644 tests/utils.test.ts diff --git a/package.json b/package.json index c1c1874..f335e85 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "format:check": "prettier --check .", "lint": "eslint --max-warnings=0 --no-warn-ignored packages/obsidian/src/**", "lint:fix": "eslint --max-warnings=0 --fix --no-warn-ignored packages/obsidian/src/**", - "check": "bun run format:check && bun run typecheck && bun run lint", - "check:fix": "bun run format && bun run typecheck && bun run lint:fix", + "check": "bun run format:check && bun run typecheck && bun run lint && bun run test:log", + "check:fix": "bun run format && bun run typecheck && bun run lint:fix && bun run test:log", "release": "lemons-automation release" }, "keywords": [], diff --git a/packages/obsidian/src/settings/PropertyMapping.ts b/packages/obsidian/src/settings/PropertyMapping.ts index 62e86c8..24d134a 100644 --- a/packages/obsidian/src/settings/PropertyMapping.ts +++ b/packages/obsidian/src/settings/PropertyMapping.ts @@ -66,7 +66,7 @@ export class PropertyMappingModel { } // remapped properties may not have the same name as any original property for (const property of this.getMappedProperties()) { - const propertiesWithSameTarget = this.properties.filter(x => x.newProperty === property.property); + const propertiesWithSameTarget = this.properties.filter(x => x.property === property.newProperty); if (propertiesWithSameTarget.length === 0) { // all good } else { diff --git a/tests/api-model.test.ts b/tests/api-model.test.ts new file mode 100644 index 0000000..6db0f71 --- /dev/null +++ b/tests/api-model.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from 'bun:test'; +import { APIModel, isSeasonListAPIModel } from 'packages/obsidian/src/api/APIModel'; +import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel'; +import type { MDBError } from 'packages/obsidian/src/utils/MDBError'; +import { MediaType } from 'packages/obsidian/src/utils/MediaType'; +import type { Result } from 'packages/obsidian/src/utils/result'; +import { ok } from 'packages/obsidian/src/utils/result'; + +class FakeAPI extends APIModel { + apiName = 'fake'; + apiUrl = 'https://example.test'; + apiDescription = 'Fake API'; + types = [MediaType.Movie, MediaType.Series]; + disabledTypes: MediaType[] = []; + + async searchByTitle(): Promise> { + return ok([]); + } + + async getById(): Promise> { + throw new Error('not needed'); + } + + getDisabledMediaTypes(): MediaType[] { + return this.disabledTypes; + } +} + +test('APIModel respects configured and disabled media types', () => { + const api = new FakeAPI(); + + expect(api.hasType(MediaType.Movie)).toBe(true); + expect(api.hasType(MediaType.Book)).toBe(false); + expect(api.hasTypeOverlap([MediaType.Book, MediaType.Series])).toBe(true); + + api.disabledTypes = [MediaType.Series]; + + expect(api.hasType(MediaType.Series)).toBe(false); + expect(api.hasTypeOverlap([MediaType.Book, MediaType.Series])).toBe(false); +}); + +test('isSeasonListAPIModel detects season-capable APIs', () => { + const api = new FakeAPI(); + + expect(isSeasonListAPIModel(undefined)).toBe(false); + expect(isSeasonListAPIModel(api)).toBe(false); + + api.getSeasonsForSeries = async () => ok([]); + + expect(isSeasonListAPIModel(api)).toBe(true); +}); diff --git a/tests/media-db-file-helper.test.ts b/tests/media-db-file-helper.test.ts new file mode 100644 index 0000000..b0c0e99 --- /dev/null +++ b/tests/media-db-file-helper.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from 'bun:test'; +import type { TFile } from 'obsidian'; +import { MediaDbFileHelper } from 'packages/obsidian/src/utils/MediaDbFileHelper'; + +function createHelper(fileContent = '', frontmatter: Record = {}): MediaDbFileHelper { + return new MediaDbFileHelper({ + app: { + metadataCache: { + getFileCache: () => ({ frontmatter }), + }, + vault: { + read: async () => fileContent, + }, + }, + } as never); +} + +test('getMetaDataFromFileContent parses frontmatter and ignores body-only files', () => { + const helper = createHelper(); + + expect(helper.getMetaDataFromFileContent('---\ntitle: Arrival\nyear: 2016\nwatched: true\n---\nBody')).toEqual({ + title: 'Arrival', + year: 2016, + watched: true, + }); + expect(helper.getMetaDataFromFileContent('Body only')).toEqual({}); +}); + +test('attachTemplate merges template metadata behind existing metadata and appends template body', async () => { + const helper = createHelper(); + + const result = await helper.attachTemplate({ title: 'Arrival', id: '1' }, 'Existing\n', '---\ntitle: Template\nrating: 8\n---\nTemplate body'); + + expect(result.fileMetadata).toEqual({ title: 'Arrival', rating: 8, id: '1' }); + expect(result.fileContent).toBe('Existing\n\nTemplate body'); +}); + +test('attachFile merges attached note metadata behind generated metadata and strips frontmatter', async () => { + const helper = createHelper('---\ntitle: Old title\ncustom: keep\n---\nAttached body', { title: 'Old title', custom: 'keep' }); + const file = { path: 'old.md' } as TFile; + + const result = await helper.attachFile({ title: 'New title', id: '1' }, '', file); + + expect(result.fileMetadata).toEqual({ title: 'New title', custom: 'keep', id: '1' }); + expect(result.fileContent).toBe('Attached body'); +}); + +test('getMetadataFromFileCache returns a clone instead of cache object reference', () => { + const frontmatter = { title: 'Arrival', nested: { rating: 9 } }; + const helper = createHelper('', frontmatter); + const metadata = helper.getMetadataFromFileCache({ path: 'movie.md' } as TFile); + + (metadata.nested as { rating: number }).rating = 1; + + expect(frontmatter.nested.rating).toBe(9); +}); diff --git a/tests/media-type-models.test.ts b/tests/media-type-models.test.ts new file mode 100644 index 0000000..4a9fba8 --- /dev/null +++ b/tests/media-type-models.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from 'bun:test'; +import { BoardGameModel } from 'packages/obsidian/src/models/BoardGameModel'; +import { BookModel } from 'packages/obsidian/src/models/BookModel'; +import { ComicMangaModel } from 'packages/obsidian/src/models/ComicMangaModel'; +import { GameModel } from 'packages/obsidian/src/models/GameModel'; +import { MovieModel } from 'packages/obsidian/src/models/MovieModel'; +import { MusicReleaseModel } from 'packages/obsidian/src/models/MusicReleaseModel'; +import { SeasonModel } from 'packages/obsidian/src/models/SeasonModel'; +import { SeasonSearchResultModel } from 'packages/obsidian/src/models/SeasonSearchResultModel'; +import { SeriesModel } from 'packages/obsidian/src/models/SeriesModel'; +import { WikiModel } from 'packages/obsidian/src/models/WikiModel'; +import { MediaType } from 'packages/obsidian/src/utils/MediaType'; +import { MediaTypeManager } from 'packages/obsidian/src/utils/MediaTypeManager'; + +test('MediaTypeManager creates the expected model for every supported media type', () => { + const manager = new MediaTypeManager(); + + const cases = [ + [MediaType.BoardGame, BoardGameModel], + [MediaType.Book, BookModel], + [MediaType.ComicManga, ComicMangaModel], + [MediaType.Game, GameModel], + [MediaType.Movie, MovieModel], + [MediaType.MusicRelease, MusicReleaseModel], + [MediaType.Season, SeasonModel], + [MediaType.Series, SeriesModel], + [MediaType.Wiki, WikiModel], + ] as const; + + for (const [mediaType, Model] of cases) { + const model = manager.createMediaTypeModelFromMediaType({ title: 'Example' }, mediaType); + + expect(model).toBeInstanceOf(Model); + expect(model.type).toBe(mediaType); + expect(model.getMediaType()).toBe(mediaType); + } + + expect(() => manager.createMediaTypeModelFromMediaType({}, 'unknown' as MediaType)).toThrow('Unknown media type: unknown'); +}); + +test('MediaTypeModel metadata flattens user data and adds slash-separated tags', () => { + const movie = new MovieModel({ + title: 'Arrival', + englishTitle: 'Arrival', + year: '2016', + userData: { watched: true, lastWatched: '2026-01-02', personalRating: 9 }, + }); + + expect(movie.toMetaDataObject()).toMatchObject({ + title: 'Arrival', + watched: true, + lastWatched: '2026-01-02', + personalRating: 9, + tags: 'mediaDB/tv/movie', + }); + expect(movie.toMetaDataObject()).not.toHaveProperty('userData'); +}); + +test('legacy flat user data is migrated into model userData defaults', () => { + const book = new BookModel({ title: 'Dune', read: true, personalRating: 8 } as never); + + expect(book.userData).toEqual({ read: true, lastRead: '', personalRating: 8 }); + expect(book.type).toBe(MediaType.Book); +}); + +test('models with custom tag and metadata behavior preserve their special cases', () => { + const manga = new ComicMangaModel({ title: 'Berserk', subType: 'manga' }); + const wiki = new WikiModel({ title: 'Obsidian', article: 'Long article text' }); + const music = new MusicReleaseModel({ title: 'Kind of Blue', year: '1959', artists: ['Miles Davis'], subType: 'album' }); + const seasonSearchResult = new SeasonSearchResultModel({ seasonCount: 1 }); + + expect(manga.getTags()).toEqual(['mediaDB', 'manga']); + expect(wiki.getWithOutUserData()).not.toHaveProperty('article'); + expect(music.getSummary()).toBe('Kind of Blue (1959) - Miles Davis'); + expect(seasonSearchResult.getSummary()).toBe('1 season'); +}); diff --git a/tests/placeholder.test.ts b/tests/placeholder.test.ts deleted file mode 100644 index a1c414e..0000000 --- a/tests/placeholder.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { test, expect } from 'bun:test'; - -test('placeholder test', () => { - expect(true).toBe(true); -}); diff --git a/tests/property-mapping.test.ts b/tests/property-mapping.test.ts new file mode 100644 index 0000000..93470f6 --- /dev/null +++ b/tests/property-mapping.test.ts @@ -0,0 +1,116 @@ +import { expect, test } from 'bun:test'; +import { PropertyMapper } from 'packages/obsidian/src/settings/PropertyMapper'; +import { PropertyMapping, PropertyMappingModel, PropertyMappingOption } from 'packages/obsidian/src/settings/PropertyMapping'; +import { MediaType } from 'packages/obsidian/src/utils/MediaType'; +import { PropertyMappingNameConflictError, PropertyMappingValidationError } from 'packages/obsidian/src/utils/Utils'; + +test('PropertyMapping validates locked and remapped property constraints', () => { + expect(new PropertyMapping('title', 'title', PropertyMappingOption.Default, true).validate()).toEqual({ res: true }); + + const lockedRemoval = new PropertyMapping('title', 'title', PropertyMappingOption.Remove, true).validate(); + expect(lockedRemoval.res).toBe(false); + expect(lockedRemoval.err).toBeInstanceOf(PropertyMappingValidationError); + + const invalidSource = new PropertyMapping('bad property', 'name', PropertyMappingOption.Map).validate(); + expect(invalidSource.res).toBe(false); + expect(invalidSource.err).toBeInstanceOf(PropertyMappingValidationError); + + const invalidTarget = new PropertyMapping('title', 'bad-property', PropertyMappingOption.Map).validate(); + expect(invalidTarget.res).toBe(false); + expect(invalidTarget.err).toBeInstanceOf(PropertyMappingValidationError); +}); + +test('PropertyMappingModel detects conflicting mapped property names', () => { + const duplicateTarget = new PropertyMappingModel(MediaType.Movie, [ + new PropertyMapping('title', 'name', PropertyMappingOption.Map), + new PropertyMapping('englishTitle', 'name', PropertyMappingOption.Map), + ]).validate(); + + expect(duplicateTarget.res).toBe(false); + expect(duplicateTarget.err).toBeInstanceOf(PropertyMappingNameConflictError); + + const targetMatchesOriginal = new PropertyMappingModel(MediaType.Movie, [ + new PropertyMapping('title', 'year', PropertyMappingOption.Map), + new PropertyMapping('year', 'year', PropertyMappingOption.Default), + ]).validate(); + + expect(targetMatchesOriginal.res).toBe(false); + expect(targetMatchesOriginal.err).toBeInstanceOf(PropertyMappingNameConflictError); +}); + +test('PropertyMappingModel serializes, copies, and migrates without sharing mutable property objects', () => { + const model = new PropertyMappingModel(MediaType.Movie, [new PropertyMapping('title', 'name', PropertyMappingOption.Map, false, true)]); + const copy = model.copy(); + + copy.properties[0].newProperty = 'label'; + + expect(model.properties[0].newProperty).toBe('name'); + expect(PropertyMappingModel.fromJSON(model.toJSON())).toEqual(model); + + const migrated = PropertyMappingModel.migrateModels( + [ + { + type: MediaType.Movie, + properties: [{ property: 'title', newProperty: 'customTitle', mapping: PropertyMappingOption.Map, locked: false, wikilink: true }], + }, + ], + [ + new PropertyMappingModel(MediaType.Movie, [ + new PropertyMapping('title', 'title', PropertyMappingOption.Default, true), + new PropertyMapping('year', 'year', PropertyMappingOption.Default, false), + ]), + new PropertyMappingModel(MediaType.Book, [new PropertyMapping('author', 'author', PropertyMappingOption.Default)]), + ], + ); + + expect(migrated).toEqual([ + new PropertyMappingModel(MediaType.Movie, [ + new PropertyMapping('title', 'customTitle', PropertyMappingOption.Map, true, true), + new PropertyMapping('year', 'year', PropertyMappingOption.Default, false), + ]), + new PropertyMappingModel(MediaType.Book, [new PropertyMapping('author', 'author', PropertyMappingOption.Default)]), + ]); +}); + +test('PropertyMapper converts objects according to mapping and wikilink rules', () => { + const mapper = new PropertyMapper({ + settings: { + propertyMappingModels: [ + new PropertyMappingModel(MediaType.Movie, [ + new PropertyMapping('title', 'name', PropertyMappingOption.Map, false, true), + new PropertyMapping('genres', 'genres', PropertyMappingOption.Default, false, true), + new PropertyMapping('year', 'year', PropertyMappingOption.Remove), + ]), + ], + }, + } as never); + + expect( + mapper.convertObject({ + type: MediaType.Movie, + title: 'Arrival', + genres: ['Drama', 2016], + year: '2016', + rating: 8, + }), + ).toEqual({ + type: MediaType.Movie, + name: '[[Arrival]]', + genres: ['[[Drama]]', 2016], + rating: 8, + }); +}); + +test('PropertyMapper restores mapped properties and migrates legacy manga type', () => { + const mapper = new PropertyMapper({ + settings: { + propertyMappingModels: [new PropertyMappingModel(MediaType.ComicManga, [new PropertyMapping('title', 'name', PropertyMappingOption.Map)])], + }, + } as never); + + expect(mapper.convertObjectBack({ type: 'manga', name: 'Berserk', year: '1989' })).toEqual({ + type: MediaType.ComicManga, + title: 'Berserk', + year: '1989', + }); +}); diff --git a/tests/result.test.ts b/tests/result.test.ts new file mode 100644 index 0000000..65ba320 --- /dev/null +++ b/tests/result.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from 'bun:test'; +import { andThen, cancelled, err, failure, fromPromise, isErr, isOk, mapError, mapResult, ok, OutcomeStatus, skipped, success, tapError } from 'packages/obsidian/src/utils/result'; +import type { Result } from 'packages/obsidian/src/utils/result'; + +test('Result helpers construct and narrow ok and error values', () => { + const good = ok(2); + const bad = err('failed'); + + expect(isOk(good)).toBe(true); + expect(isErr(good)).toBe(false); + expect(isOk(bad)).toBe(false); + expect(isErr(bad)).toBe(true); +}); + +test('Result combinators map only the matching branch', () => { + const good: Result = ok(2); + const bad: Result = err('failed'); + let tappedError = ''; + + expect(mapResult(good, value => value * 2)).toEqual(ok(4)); + expect(mapResult(bad, value => value * 2)).toBe(bad); + expect(mapError(good, error => error.toUpperCase())).toBe(good); + expect(mapError(bad, error => error.toUpperCase())).toEqual(err('FAILED')); + expect(andThen(good, value => ok(String(value)))).toEqual(ok('2')); + expect(andThen(bad, value => ok(String(value)))).toBe(bad); + expect(tapError(bad, error => (tappedError = error))).toBe(bad); + expect(tappedError).toBe('failed'); +}); + +test('fromPromise captures resolved values and maps rejection causes', async () => { + await expect(fromPromise(Promise.resolve('done'), String)).resolves.toEqual(ok('done')); + await expect(fromPromise(Promise.reject(new Error('boom')), cause => (cause as Error).message)).resolves.toEqual(err('boom')); +}); + +test('Outcome helpers construct the expected statuses', () => { + expect(success('done')).toEqual({ status: OutcomeStatus.Ok, data: 'done' }); + expect(failure('bad')).toEqual({ status: OutcomeStatus.Error, error: 'bad' }); + expect(cancelled()).toEqual({ status: OutcomeStatus.Cancelled }); + expect(skipped()).toEqual({ status: OutcomeStatus.Skipped }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index a2a5c16..6871828 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,5 +1,41 @@ import { mock } from 'bun:test'; +function parseScalar(value: string): unknown { + if (value === 'true') return true; + if (value === 'false') return false; + if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value); + return value.replace(/^['"]|['"]$/g, ''); +} + +function parseSimpleYaml(yaml: string): Record { + const result: Record = {}; + + for (const line of yaml.split('\n')) { + const match = /^([^:#][^:]*):\s*(.*)$/.exec(line.trim()); + if (!match) continue; + + const [, key, value] = match; + result[key] = + value.startsWith('[') && value.endsWith(']') + ? value + .slice(1, -1) + .split(',') + .map(item => parseScalar(item.trim())) + : parseScalar(value); + } + + return result; +} + +function stringifySimpleYaml(value: unknown): string { + if (!value || typeof value !== 'object') return String(value); + + return Object.entries(value as Record) + .map(([key, entry]) => `${key}: ${Array.isArray(entry) ? `[${entry.join(', ')}]` : String(entry)}`) + .join('\n') + .concat('\n'); +} + mock.module('obsidian', () => ({ AbstractInputSuggest: class {}, Component: class { @@ -22,14 +58,14 @@ mock.module('obsidian', () => ({ Notice: class {}, normalizePath: (path: string): string => path, moment: Object.assign((value?: unknown): unknown => value, { locale: (): void => {} }), - parseYaml: (): unknown => ({}), + parseYaml: parseSimpleYaml, Plugin: class {}, PluginSettingTab: class {}, requestUrl: async (): Promise => ({}), SecretComponent: class {}, Setting: class {}, SettingGroup: class {}, - stringifyYaml: (value: unknown): string => String(value), + stringifyYaml: stringifySimpleYaml, TFile: class {}, TFolder: class {}, TextComponent: class {}, diff --git a/tests/utils.test.ts b/tests/utils.test.ts new file mode 100644 index 0000000..2f87a5a --- /dev/null +++ b/tests/utils.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from 'bun:test'; +import { MovieModel } from 'packages/obsidian/src/models/MovieModel'; +import { markdownTable, migrateObject, replaceTags, wrapAround } from 'packages/obsidian/src/utils/Utils'; + +test('replaceTags substitutes nested values and array operators', () => { + const movie = new MovieModel({ + title: 'Alien', + year: '1979', + genres: ['Horror', 'Sci-Fi'], + userData: { watched: true, lastWatched: '2026-01-02', personalRating: 9 }, + }); + + expect(replaceTags('{{ title }} ({{ year }}) - {{ userData.personalRating }}', movie)).toBe('Alien (1979) - 9'); + expect(replaceTags('{{ ENUM:genres }}', movie)).toBe('Horror, Sci-Fi'); + expect(replaceTags('{{ LIST:genres }}', movie)).toBe('- Horror\n- Sci-Fi'); + expect(replaceTags('{{ FIRST:genres }} / {{ LAST:genres }}', movie)).toBe('Horror / Sci-Fi'); +}); + +test('replaceTags reports invalid tags unless undefined values are ignored', () => { + const movie = new MovieModel({ title: 'Alien' }); + + expect(replaceTags('{{ missing }}', movie)).toBe('{{ INVALID TEMPLATE TAG - object undefined }}'); + expect(replaceTags('{{ missing }}', movie, true)).toBe(''); + expect(replaceTags('{{ ENUM:title }}', movie)).toBe('{{ INVALID TEMPLATE TAG - operator ENUM is only applicable on an array }}'); + expect(replaceTags('{{ UNKNOWN:genres }}', movie)).toBe('{{ INVALID TEMPLATE TAG - unknown operator UNKNOWN }}'); + expect(replaceTags('{{ TOO:MANY:PARTS }}', movie)).toBe('{{ INVALID TEMPLATE TAG }}'); +}); + +test('markdownTable aligns columns and rejects jagged input', () => { + expect( + markdownTable([ + ['Name', 'Year'], + ['Alien', '1979'], + ['Arrival', '2016'], + ]), + ).toBe('| Name | Year |\n| ------- | ---- |\n| Alien | 1979 |\n| Arrival | 2016 |\n'); + + expect(markdownTable([])).toBe(''); + expect(markdownTable([[]])).toBe(''); + expect(markdownTable([['Name'], ['Alien', '1979']])).toBe(''); +}); + +test('migrateObject keeps existing defined values and fills missing values from defaults', () => { + const target = { title: '', year: '', rating: 0 }; + const defaults = { title: 'Untitled', year: '1900', rating: 1 }; + + migrateObject(target, { title: 'Arrival', year: null, rating: undefined }, defaults); + + expect(target).toEqual({ title: 'Arrival', year: '1900', rating: 1 }); +}); + +test('wrapAround uses positive modulo and rejects invalid sizes', () => { + expect(wrapAround(5, 3)).toBe(2); + expect(wrapAround(-1, 3)).toBe(2); + expect(() => wrapAround(1, 0)).toThrow('size may not be zero or negative'); +});