more tests
This commit is contained in:
parent
84a82f521a
commit
80adf4aff7
10 changed files with 436 additions and 10 deletions
|
|
@ -13,8 +13,8 @@
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"lint": "eslint --max-warnings=0 --no-warn-ignored packages/obsidian/src/**",
|
"lint": "eslint --max-warnings=0 --no-warn-ignored packages/obsidian/src/**",
|
||||||
"lint:fix": "eslint --max-warnings=0 --fix --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": "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",
|
"check:fix": "bun run format && bun run typecheck && bun run lint:fix && bun run test:log",
|
||||||
"release": "lemons-automation release"
|
"release": "lemons-automation release"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ export class PropertyMappingModel {
|
||||||
}
|
}
|
||||||
// remapped properties may not have the same name as any original property
|
// remapped properties may not have the same name as any original property
|
||||||
for (const property of this.getMappedProperties()) {
|
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) {
|
if (propertiesWithSameTarget.length === 0) {
|
||||||
// all good
|
// all good
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
51
tests/api-model.test.ts
Normal file
51
tests/api-model.test.ts
Normal 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);
|
||||||
|
});
|
||||||
56
tests/media-db-file-helper.test.ts
Normal file
56
tests/media-db-file-helper.test.ts
Normal 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);
|
||||||
|
});
|
||||||
76
tests/media-type-models.test.ts
Normal file
76
tests/media-type-models.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { test, expect } from 'bun:test';
|
|
||||||
|
|
||||||
test('placeholder test', () => {
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
116
tests/property-mapping.test.ts
Normal file
116
tests/property-mapping.test.ts
Normal 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
40
tests/result.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,41 @@
|
||||||
import { mock } from 'bun:test';
|
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', () => ({
|
mock.module('obsidian', () => ({
|
||||||
AbstractInputSuggest: class {},
|
AbstractInputSuggest: class {},
|
||||||
Component: class {
|
Component: class {
|
||||||
|
|
@ -22,14 +58,14 @@ mock.module('obsidian', () => ({
|
||||||
Notice: class {},
|
Notice: class {},
|
||||||
normalizePath: (path: string): string => path,
|
normalizePath: (path: string): string => path,
|
||||||
moment: Object.assign((value?: unknown): unknown => value, { locale: (): void => {} }),
|
moment: Object.assign((value?: unknown): unknown => value, { locale: (): void => {} }),
|
||||||
parseYaml: (): unknown => ({}),
|
parseYaml: parseSimpleYaml,
|
||||||
Plugin: class {},
|
Plugin: class {},
|
||||||
PluginSettingTab: class {},
|
PluginSettingTab: class {},
|
||||||
requestUrl: async (): Promise<unknown> => ({}),
|
requestUrl: async (): Promise<unknown> => ({}),
|
||||||
SecretComponent: class {},
|
SecretComponent: class {},
|
||||||
Setting: class {},
|
Setting: class {},
|
||||||
SettingGroup: class {},
|
SettingGroup: class {},
|
||||||
stringifyYaml: (value: unknown): string => String(value),
|
stringifyYaml: stringifySimpleYaml,
|
||||||
TFile: class {},
|
TFile: class {},
|
||||||
TFolder: class {},
|
TFolder: class {},
|
||||||
TextComponent: class {},
|
TextComponent: class {},
|
||||||
|
|
|
||||||
56
tests/utils.test.ts
Normal file
56
tests/utils.test.ts
Normal 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');
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue