more tests

This commit is contained in:
Moritz Jung 2026-07-03 11:38:15 +02:00
parent 84a82f521a
commit 80adf4aff7
10 changed files with 436 additions and 10 deletions

View file

@ -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": [],

View file

@ -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 {

51
tests/api-model.test.ts Normal file
View file

@ -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<Result<MediaTypeModel[], MDBError>> {
return ok([]);
}
async getById(): Promise<Result<MediaTypeModel, MDBError>> {
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);
});

View file

@ -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<string, unknown> = {}): 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);
});

View file

@ -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');
});

View file

@ -1,5 +0,0 @@
import { test, expect } from 'bun:test';
test('placeholder test', () => {
expect(true).toBe(true);
});

View file

@ -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',
});
});

40
tests/result.test.ts Normal file
View file

@ -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<number, string> = ok(2);
const bad: Result<number, string> = err('failed');
let tappedError = '';
expect(mapResult(good, value => value * 2)).toEqual(ok(4));
expect(mapResult<number, string, number>(bad, value => value * 2)).toBe(bad);
expect(mapError<number, string, string>(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 });
});

View file

@ -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<string, unknown> {
const result: Record<string, unknown> = {};
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<string, unknown>)
.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<unknown> => ({}),
SecretComponent: class {},
Setting: class {},
SettingGroup: class {},
stringifyYaml: (value: unknown): string => String(value),
stringifyYaml: stringifySimpleYaml,
TFile: class {},
TFolder: class {},
TextComponent: class {},

56
tests/utils.test.ts Normal file
View file

@ -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');
});