From 18790b96c1dc900ae5bba201f2d611d068b54d1d Mon Sep 17 00:00:00 2001 From: CodeWithMaBot <310216433+CodeWithMaBot@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:31:51 +0200 Subject: [PATCH 01/11] docs: add single test command examples to AGENTS.md --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 9ee9fc5..96b7db9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,2 +1,4 @@ - **No Amending:** Never use `git commit --amend`. Always create new, discrete commits for every set of changes. - This project uses bun. Examples: `bun run lint`, `bun run build`, `bun run test --no-watch`. +- Run a single spec file: `bun run test -- --include="src/app/utils/form.utils.spec.ts" --no-watch` +- Run a single test by name (regex): `bun run test -- --filter="toPositiveNumber" --no-watch` From 8ffb9565e92ab88365e10294e73f40f9acfa3bac Mon Sep 17 00:00:00 2001 From: CodeWithMaBot <310216433+CodeWithMaBot@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:29:32 +0200 Subject: [PATCH 02/11] docs: add more about the project and development to AGENTS.md --- AGENTS.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 96b7db9..2143705 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,15 @@ -- **No Amending:** Never use `git commit --amend`. Always create new, discrete commits for every set of changes. +This is my personal watch-list app. +The main function is to show me what tv series or movie I should watch next. + +## Development + +### Taste + +- No Amending. Never use `git commit --amend`. Always create new, discrete commits for every set of changes. +- Inferred types over annotations. `any` is the enemy. + +### Build and Test + - This project uses bun. Examples: `bun run lint`, `bun run build`, `bun run test --no-watch`. - Run a single spec file: `bun run test -- --include="src/app/utils/form.utils.spec.ts" --no-watch` - Run a single test by name (regex): `bun run test -- --filter="toPositiveNumber" --no-watch` From 9ed5f24a0d3c412dec03da9c92e45d6d21e9bc84 Mon Sep 17 00:00:00 2001 From: CodeWithMaBot <310216433+CodeWithMaBot@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:52:20 +0200 Subject: [PATCH 03/11] refactor: unify suggestion model in preparation for Jikan source --- .../add-item/add-item.component.spec.ts | 165 ++++++++---------- .../components/add-item/add-item.component.ts | 12 +- .../item-form/item-form.component.spec.ts | 6 +- .../item-form/item-form.component.ts | 12 +- .../poster-picker.component.spec.ts | 116 ++++++------ .../poster-picker/poster-picker.component.ts | 36 ++-- src/app/models/suggestion.model.ts | 17 ++ src/app/models/tmdb-suggestion.model.ts | 14 -- .../services/tmdb-suggestion.service.spec.ts | 10 +- src/app/services/tmdb-suggestion.service.ts | 24 +-- ...search.utils.ts => search-stream.utils.ts} | 16 +- 11 files changed, 215 insertions(+), 213 deletions(-) create mode 100644 src/app/models/suggestion.model.ts delete mode 100644 src/app/models/tmdb-suggestion.model.ts rename src/app/utils/{tmdb-search.utils.ts => search-stream.utils.ts} (81%) diff --git a/src/app/components/add-item/add-item.component.spec.ts b/src/app/components/add-item/add-item.component.spec.ts index 6444874..40e1232 100644 --- a/src/app/components/add-item/add-item.component.spec.ts +++ b/src/app/components/add-item/add-item.component.spec.ts @@ -5,10 +5,20 @@ import { GroupService } from '../../services/group.service'; import { WatchListService } from '../../services/watch-list.service'; import { TmdbSuggestionService } from '../../services/tmdb-suggestion.service'; import { Item } from '../../models/item.model'; +import { Suggestion } from '../../models/suggestion.model'; import { AddItemComponent } from './add-item.component'; import { vi } from 'vitest'; import { Observable, Subject, of, throwError } from 'rxjs'; +function createSuggestion( + overrides: Partial & Pick, +): Suggestion { + return { + source: 'tmdb', + ...overrides, + }; +} + describe('AddItemComponent', () => { const existingItems: Item[] = [ { @@ -96,20 +106,12 @@ describe('AddItemComponent', () => { const fixture = TestBed.createComponent(AddItemComponent); fixture.componentInstance.suggestions.set([ - { - tmdbId: 1396, - title: 'Breaking Bad', - type: 'series', - year: '2008', - }, + createSuggestion({ id: 1396, title: 'Breaking Bad', type: 'series', year: '2008' }), ]); - fixture.componentInstance.onSuggestionSelected({ - tmdbId: 1396, - title: 'Breaking Bad', - type: 'series', - year: '2008', - }); + fixture.componentInstance.onSuggestionSelected( + createSuggestion({ id: 1396, title: 'Breaking Bad', type: 'series', year: '2008' }), + ); expect(fixture.componentInstance.title()).toBe('Breaking Bad'); expect(fixture.componentInstance.suggestions()).toEqual([]); @@ -131,13 +133,15 @@ describe('AddItemComponent', () => { ); const fixture = TestBed.createComponent(AddItemComponent); - fixture.componentInstance.onSuggestionSelected({ - tmdbId: 1396, - title: 'Breaking Bad', - type: 'series', - year: '2008', - posterPath: '/breaking-bad.jpg', - }); + fixture.componentInstance.onSuggestionSelected( + createSuggestion({ + id: 1396, + title: 'Breaking Bad', + type: 'series', + year: '2008', + posterUrl: 'https://image.tmdb.org/t/p/w342/breaking-bad.jpg', + }), + ); expect(tmdbSuggestionService.getSeriesDetails).toHaveBeenCalledWith(1396); expect(fixture.componentInstance.autofillPatch()).toEqual({ @@ -158,13 +162,15 @@ describe('AddItemComponent', () => { const tmdbSuggestionService = TestBed.inject(TmdbSuggestionService); const fixture = TestBed.createComponent(AddItemComponent); - fixture.componentInstance.onSuggestionSelected({ - tmdbId: 11, - title: 'Star Wars', - type: 'movie', - year: '1977', - posterPath: '/star-wars.jpg', - }); + fixture.componentInstance.onSuggestionSelected( + createSuggestion({ + id: 11, + title: 'Star Wars', + type: 'movie', + year: '1977', + posterUrl: 'https://image.tmdb.org/t/p/w342/star-wars.jpg', + }), + ); expect(tmdbSuggestionService.getSeriesDetails).not.toHaveBeenCalled(); expect(fixture.componentInstance.autofillPatch()).toBeNull(); @@ -176,12 +182,9 @@ describe('AddItemComponent', () => { vi.mocked(tmdbSuggestionService.getSeriesDetails).mockReturnValue(details.asObservable()); const fixture = TestBed.createComponent(AddItemComponent); - fixture.componentInstance.onSuggestionSelected({ - tmdbId: 1396, - title: 'Breaking Bad', - type: 'series', - year: '2008', - }); + fixture.componentInstance.onSuggestionSelected( + createSuggestion({ id: 1396, title: 'Breaking Bad', type: 'series', year: '2008' }), + ); fixture.componentInstance.onTitleChanged('Different show'); details.next({ seasons: [ @@ -201,13 +204,15 @@ describe('AddItemComponent', () => { vi.mocked(tmdbSuggestionService.getSeriesDetails).mockReturnValue(details.asObservable()); const fixture = TestBed.createComponent(AddItemComponent); - fixture.componentInstance.onSuggestionSelected({ - tmdbId: 1396, - title: 'Breaking Bad', - type: 'series', - year: '2008', - posterPath: '/breaking-bad.jpg', - }); + fixture.componentInstance.onSuggestionSelected( + createSuggestion({ + id: 1396, + title: 'Breaking Bad', + type: 'series', + year: '2008', + posterUrl: 'https://image.tmdb.org/t/p/w342/breaking-bad.jpg', + }), + ); fixture.componentInstance.onTitleChanged('Different show'); details.next({ seasons: [ @@ -227,20 +232,24 @@ describe('AddItemComponent', () => { vi.mocked(tmdbSuggestionService.getSeriesDetails).mockReturnValue(details.asObservable()); const fixture = TestBed.createComponent(AddItemComponent); - fixture.componentInstance.onSuggestionSelected({ - tmdbId: 1396, - title: 'Breaking Bad', - type: 'series', - year: '2008', - posterPath: '/breaking-bad.jpg', - }); - fixture.componentInstance.onSuggestionSelected({ - tmdbId: 11, - title: 'Star Wars', - type: 'movie', - year: '1977', - posterPath: '/star-wars.jpg', - }); + fixture.componentInstance.onSuggestionSelected( + createSuggestion({ + id: 1396, + title: 'Breaking Bad', + type: 'series', + year: '2008', + posterUrl: 'https://image.tmdb.org/t/p/w342/breaking-bad.jpg', + }), + ); + fixture.componentInstance.onSuggestionSelected( + createSuggestion({ + id: 11, + title: 'Star Wars', + type: 'movie', + year: '1977', + posterUrl: 'https://image.tmdb.org/t/p/w342/star-wars.jpg', + }), + ); details.next({ seasons: [ { @@ -284,18 +293,12 @@ describe('AddItemComponent', () => { ); const fixture = TestBed.createComponent(AddItemComponent); - fixture.componentInstance.onSuggestionSelected({ - tmdbId: 1396, - title: 'Breaking Bad', - type: 'series', - year: '2008', - }); - fixture.componentInstance.onSuggestionSelected({ - tmdbId: 66732, - title: 'Stranger Things', - type: 'series', - year: '2016', - }); + fixture.componentInstance.onSuggestionSelected( + createSuggestion({ id: 1396, title: 'Breaking Bad', type: 'series', year: '2008' }), + ); + fixture.componentInstance.onSuggestionSelected( + createSuggestion({ id: 66732, title: 'Stranger Things', type: 'series', year: '2016' }), + ); expect(firstRequestUnsubscribed).toBe(true); expect(tmdbSuggestionService.getSeriesDetails).toHaveBeenCalledWith(1396); @@ -340,14 +343,7 @@ describe('AddItemComponent', () => { search .mockReturnValueOnce(throwError(() => new Error('TMDB unavailable'))) .mockReturnValueOnce( - of([ - { - tmdbId: 1396, - title: 'Breaking Bad', - type: 'series', - year: '2008', - }, - ]), + of([createSuggestion({ id: 1396, title: 'Breaking Bad', type: 'series', year: '2008' })]), ); const fixture = TestBed.createComponent(AddItemComponent); @@ -364,12 +360,7 @@ describe('AddItemComponent', () => { await vi.advanceTimersByTimeAsync(250); expect(fixture.componentInstance.suggestions()).toEqual([ - { - tmdbId: 1396, - title: 'Breaking Bad', - type: 'series', - year: '2008', - }, + createSuggestion({ id: 1396, title: 'Breaking Bad', type: 'series', year: '2008' }), ]); expect(fixture.componentInstance.suggestionsError()).toBe(''); } finally { @@ -404,21 +395,13 @@ describe('AddItemComponent', () => { const fixture = TestBed.createComponent(AddItemComponent); fixture.componentInstance.suggestions.set([ - { - tmdbId: 1396, - title: 'Breaking Bad', - type: 'series', - year: '2008', - }, + createSuggestion({ id: 1396, title: 'Breaking Bad', type: 'series', year: '2008' }), ]); fixture.componentInstance.onTitleChanged('Breakin'); await vi.advanceTimersByTimeAsync(100); - fixture.componentInstance.onSuggestionSelected({ - tmdbId: 1396, - title: 'Breaking Bad', - type: 'series', - year: '2008', - }); + fixture.componentInstance.onSuggestionSelected( + createSuggestion({ id: 1396, title: 'Breaking Bad', type: 'series', year: '2008' }), + ); await vi.advanceTimersByTimeAsync(250); expect(search).not.toHaveBeenCalled(); diff --git a/src/app/components/add-item/add-item.component.ts b/src/app/components/add-item/add-item.component.ts index a1a5ce9..587bfaf 100644 --- a/src/app/components/add-item/add-item.component.ts +++ b/src/app/components/add-item/add-item.component.ts @@ -11,8 +11,8 @@ import { createDefaultItemFormValue, ItemFormValue, } from '../../domain/item-form'; -import { TmdbSuggestion } from '../../models/tmdb-suggestion.model'; -import { createTmdbSearchStream } from '../../utils/tmdb-search.utils'; +import { Suggestion } from '../../models/suggestion.model'; +import { createSearchStream } from '../../utils/search-stream.utils'; @Component({ selector: 'app-add-item', @@ -47,7 +47,7 @@ export class AddItemComponent { private tmdbSuggestionService = inject(TmdbSuggestionService); private router = inject(Router); private destroyRef = inject(DestroyRef); - private readonly tmdb = createTmdbSearchStream( + private readonly tmdb = createSearchStream( (query) => this.tmdbSuggestionService.search(query), 'TMDB suggestions are unavailable.', { @@ -69,7 +69,7 @@ export class AddItemComponent { readonly groups = this.groupService.groups; readonly initialValue = createDefaultItemFormValue(); readonly title = signal(''); - readonly suggestions = signal([]); + readonly suggestions = signal([]); readonly suggestionsLoading = signal(false); readonly suggestionsError = signal(''); readonly autofillPatch = signal<{ id: number; value: Partial } | null>(null); @@ -141,7 +141,7 @@ export class AddItemComponent { this.requestTitleSearch(title); } - onSuggestionSelected(suggestion: TmdbSuggestion): void { + onSuggestionSelected(suggestion: Suggestion): void { this.title.set(suggestion.title); this.skipNextSearch = true; this.requestTitleSearch(suggestion.title); @@ -154,7 +154,7 @@ export class AddItemComponent { return; } - this.selectedTmdbSeriesIds.next(suggestion.tmdbId); + this.selectedTmdbSeriesIds.next(suggestion.id); } async onSubmit(formValue: ItemFormValue): Promise { diff --git a/src/app/components/item-form/item-form.component.spec.ts b/src/app/components/item-form/item-form.component.spec.ts index d1d0dad..d75cb2b 100644 --- a/src/app/components/item-form/item-form.component.spec.ts +++ b/src/app/components/item-form/item-form.component.spec.ts @@ -125,7 +125,8 @@ describe('ItemFormComponent', () => { fixture.componentRef.setInput('groups', groups); fixture.componentRef.setInput('suggestions', [ { - tmdbId: 11, + id: 11, + source: 'tmdb', title: 'Star Wars', type: 'movie', year: '1977', @@ -153,7 +154,8 @@ describe('ItemFormComponent', () => { expect(fixture.componentInstance.formValue().type).toBe('movie'); expect(selected).toEqual([ { - tmdbId: 11, + id: 11, + source: 'tmdb', title: 'Star Wars', type: 'movie', year: '1977', diff --git a/src/app/components/item-form/item-form.component.ts b/src/app/components/item-form/item-form.component.ts index f84eb7b..7262c67 100644 --- a/src/app/components/item-form/item-form.component.ts +++ b/src/app/components/item-form/item-form.component.ts @@ -12,7 +12,7 @@ import { import { FormsModule } from '@angular/forms'; import { Group } from '../../models/group.model'; import { ItemStatus, SeasonInfo } from '../../models/item.model'; -import { TmdbSuggestion } from '../../models/tmdb-suggestion.model'; +import { Suggestion } from '../../models/suggestion.model'; import { createDefaultItemFormValue, ItemFormValue, @@ -76,7 +76,7 @@ export interface ItemFormAutofillPatch { } @else if (suggestionsError()) {
{{ suggestionsError() }}
} @else if (suggestions().length > 0) { - @for (suggestion of suggestions(); track suggestion.type + '-' + suggestion.tmdbId) { + @for (suggestion of suggestions(); track suggestion.source + '-' + suggestion.id) { } @@ -139,7 +139,7 @@ export class PosterPickerComponent { private destroyed = false; private skipDraftCleanup = false; - private readonly tmdb = createSearchStream( + private readonly searchStream = createSearchStream( (query) => this.suggestionSearchService.search(query), 'Search unavailable.', { @@ -162,7 +162,7 @@ export class PosterPickerComponent { void this.loadPosterPreview(posterId, version); }); - this.tmdb.results.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((results) => { + this.searchStream.results.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((results) => { this.posterSuggestions.set(results.filter((suggestion) => suggestion.posterUrl)); }); } @@ -177,7 +177,7 @@ export class PosterPickerComponent { onPosterSearchChanged(query: string): void { this.posterSearchQuery.set(query); - this.tmdb.query.next(query); + this.searchStream.query.next(query); } selectPosterSuggestion(suggestion: Suggestion): void { @@ -185,7 +185,7 @@ export class PosterPickerComponent { void this.storePoster(this.imageStorage.storeUrl(suggestion.posterUrl)); } this.posterSearchQuery.set(''); - this.tmdb.query.next(''); + this.searchStream.query.next(''); this.posterSuggestions.set([]); this.showPosterSearch.set(false); } diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index 94277bd..5b1865a 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -85,6 +85,32 @@ import { environment } from '../../../environments/environment'; } +
+

+ AniList +

+
+ + AniList.co + +

+ This product uses the AniList API (graphql.anilist.co) but is not endorsed or certified + by AniList. +

+

+ Anime suggestions (TV, Movie, OVA, ONA) are fetched from AniList’s public GraphQL API + with + isAdult:false filtering and no authentication required. + Rate limit: ~90 requests/min (currently 30/min degraded). +

+
+
+

Data Management diff --git a/src/app/models/suggestion.model.ts b/src/app/models/suggestion.model.ts index 82a1834..73a4268 100644 --- a/src/app/models/suggestion.model.ts +++ b/src/app/models/suggestion.model.ts @@ -1,6 +1,6 @@ import { ItemType, SeasonInfo } from './item.model'; -export type SuggestionSource = 'tmdb' | 'mal'; +export type SuggestionSource = 'tmdb' | 'mal' | 'anilist'; export interface Suggestion { id: number; diff --git a/src/app/services/anilist-suggestion.service.spec.ts b/src/app/services/anilist-suggestion.service.spec.ts new file mode 100644 index 0000000..726d49b --- /dev/null +++ b/src/app/services/anilist-suggestion.service.spec.ts @@ -0,0 +1,354 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { AnilistSuggestionService } from './anilist-suggestion.service'; + +describe('AnilistSuggestionService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + }); + + afterEach(() => { + TestBed.inject(HttpTestingController).verify(); + }); + + it('searches AniList and maps TV, TV_SHORT, movie, OVA, and ONA results', () => { + const service = TestBed.inject(AnilistSuggestionService); + const http = TestBed.inject(HttpTestingController); + let suggestions: unknown; + + service.search('cowboy bebop').subscribe((results) => { + suggestions = results; + }); + + const request = http.expectOne((req) => req.url === 'https://graphql.anilist.co'); + expect(request.request.method).toBe('POST'); + const body = request.request.body as { query: string; variables: { search: string } }; + expect(body.variables.search).toBe('cowboy bebop'); + expect(body.query).toContain('media(search:'); + expect(body.query).toContain('isAdult: false'); + + request.flush({ + data: { + Page: { + media: [ + { + id: 1, + title: { + romaji: 'Cowboy Bebop', + english: 'Cowboy Bebop', + native: 'カウボーイビバップ', + }, + format: 'TV', + episodes: 26, + startDate: { year: 1998, month: 4, day: 3 }, + coverImage: { + extraLarge: 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx1.jpg', + }, + description: 'Crime is timeless.
Space western.', + }, + { + id: 5, + title: { romaji: 'Cowboy Bebop: The Movie', english: null, native: null }, + format: 'MOVIE', + episodes: 1, + startDate: { year: 2001, month: 9, day: 1 }, + }, + { + id: 6, + title: { romaji: 'OVA Title', english: null }, + format: 'OVA', + episodes: 4, + startDate: { year: 2020, month: 1, day: 1 }, + coverImage: { + large: 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6.jpg', + }, + }, + { + id: 7, + title: { english: 'ONA Title', romaji: '', native: '' }, + format: 'ONA', + episodes: 12, + startDate: { year: 2021, month: 6, day: 1 }, + }, + { + id: 10, + title: { romaji: 'TV Short Title' }, + format: 'TV_SHORT', + episodes: 12, + startDate: { year: 2022, month: 3, day: 5 }, + }, + { + id: 8, + title: { romaji: 'Special Title' }, + format: 'SPECIAL', + episodes: 1, + }, + { + id: 9, + title: { romaji: 'Music Title' }, + format: 'MUSIC', + episodes: 1, + }, + ], + }, + }, + }); + + expect(suggestions).toEqual([ + { + id: 1, + source: 'anilist', + title: 'Cowboy Bebop', + type: 'series', + year: '1998', + overview: 'Crime is timeless. Space western.', + posterUrl: 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx1.jpg', + }, + { + id: 5, + source: 'anilist', + title: 'Cowboy Bebop: The Movie', + type: 'movie', + year: '2001', + overview: undefined, + posterUrl: undefined, + }, + { + id: 6, + source: 'anilist', + title: 'OVA Title', + type: 'ova', + year: '2020', + overview: undefined, + posterUrl: 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6.jpg', + }, + { + id: 7, + source: 'anilist', + title: 'ONA Title', + type: 'ona', + year: '2021', + overview: undefined, + posterUrl: undefined, + }, + { + id: 10, + source: 'anilist', + title: 'TV Short Title', + type: 'series', + year: '2022', + overview: undefined, + posterUrl: undefined, + }, + ]); + }); + + it('prefers romaji title then english then native', () => { + const service = TestBed.inject(AnilistSuggestionService); + const http = TestBed.inject(HttpTestingController); + let suggestions: unknown; + + service.search('test').subscribe((results) => { + suggestions = results; + }); + + http + .expectOne((req) => req.url === 'https://graphql.anilist.co') + .flush({ + data: { + Page: { + media: [ + { + id: 100, + title: { romaji: '', english: 'English Title', native: 'Native Title' }, + format: 'TV', + episodes: 12, + startDate: { year: 2023, month: 1, day: 1 }, + }, + { + id: 101, + title: { romaji: '', english: '', native: 'Native Only' }, + format: 'TV', + episodes: 12, + startDate: { year: 2023, month: 1, day: 1 }, + }, + { + id: 102, + title: { romaji: '', english: '', native: '' }, + format: 'TV', + episodes: 12, + startDate: { year: 2023, month: 1, day: 1 }, + }, + ], + }, + }, + }); + + expect(suggestions).toEqual([ + { + id: 100, + source: 'anilist', + title: 'English Title', + type: 'series', + year: '2023', + overview: undefined, + posterUrl: undefined, + }, + { + id: 101, + source: 'anilist', + title: 'Native Only', + type: 'series', + year: '2023', + overview: undefined, + posterUrl: undefined, + }, + ]); + }); + + it('fetches anime details and maps to a single season entry', () => { + const service = TestBed.inject(AnilistSuggestionService); + const http = TestBed.inject(HttpTestingController); + let details: unknown; + + service.getAnimeDetails(1).subscribe((result) => { + details = result; + }); + + const request = http.expectOne((req) => req.url === 'https://graphql.anilist.co'); + expect(request.request.method).toBe('POST'); + const body = request.request.body as { variables: { id: number } }; + expect(body.variables.id).toBe(1); + + request.flush({ + data: { + Media: { + id: 1, + format: 'TV', + episodes: 26, + startDate: { year: 1998, month: 4, day: 3 }, + }, + }, + }); + + expect(details).toEqual({ + seasons: [ + { + seasonNumber: 1, + totalEpisodes: 26, + firstEpisodeAirDate: '1998-04-03', + }, + ], + }); + }); + + it('handles ongoing anime with null episodes and missing air date', () => { + const service = TestBed.inject(AnilistSuggestionService); + const http = TestBed.inject(HttpTestingController); + let details: unknown; + + service.getAnimeDetails(999).subscribe((result) => { + details = result; + }); + + http + .expectOne((req) => req.url === 'https://graphql.anilist.co') + .flush({ + data: { + Media: { + id: 999, + format: 'TV', + episodes: null, + startDate: { year: null, month: null, day: null }, + }, + }, + }); + + expect(details).toEqual({ seasons: [] }); + }); + + it('returns no suggestions for short queries and invalid ids', () => { + const service = TestBed.inject(AnilistSuggestionService); + const http = TestBed.inject(HttpTestingController); + let results: unknown; + + service.search('b').subscribe((suggestions) => { + results = suggestions; + }); + service.getAnimeDetails(0).subscribe((details) => { + expect(details).toBeNull(); + }); + service.getAnimeDetails(1.5).subscribe((details) => { + expect(details).toBeNull(); + }); + + expect(results).toEqual([]); + http.expectNone('https://graphql.anilist.co'); + http.verify(); + }); + + it('propagates HTTP errors to callers', () => { + const service = TestBed.inject(AnilistSuggestionService); + const http = TestBed.inject(HttpTestingController); + let errorStatus: number | undefined; + + service.search('cowboy').subscribe({ + error: (error: { status?: number }) => { + errorStatus = error.status; + }, + }); + + http + .expectOne((req) => req.url === 'https://graphql.anilist.co') + .flush( + { errors: [{ message: 'Too Many Requests.', status: 429 }] }, + { status: 429, statusText: 'Too Many Requests' }, + ); + + expect(errorStatus).toBe(429); + }); + + it('strips HTML tags and decodes entities in overview', () => { + const service = TestBed.inject(AnilistSuggestionService); + const http = TestBed.inject(HttpTestingController); + let suggestions: unknown; + + service.search('test').subscribe((results) => { + suggestions = results; + }); + + http + .expectOne((req) => req.url === 'https://graphql.anilist.co') + .flush({ + data: { + Page: { + media: [ + { + id: 200, + title: { romaji: 'Html Anime' }, + format: 'TV', + episodes: 12, + startDate: { year: 2024, month: 7, day: 1 }, + description: 'Great anime & more
Next line.', + }, + ], + }, + }, + }); + + expect(suggestions).toEqual([ + { + id: 200, + source: 'anilist', + title: 'Html Anime', + type: 'series', + year: '2024', + overview: 'Great anime & more Next line.', + posterUrl: undefined, + }, + ]); + }); +}); diff --git a/src/app/services/anilist-suggestion.service.ts b/src/app/services/anilist-suggestion.service.ts new file mode 100644 index 0000000..17cb3e8 --- /dev/null +++ b/src/app/services/anilist-suggestion.service.ts @@ -0,0 +1,298 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { map, Observable, of } from 'rxjs'; +import { SeriesDetails, Suggestion } from '../models/suggestion.model'; +import { ItemType } from '../models/item.model'; + +interface AnilistSearchResponse { + data?: { + Page?: { + media?: unknown[]; + }; + }; +} + +interface AnilistDetailsResponse { + data?: { + Media?: unknown; + }; +} + +interface AnilistMedia { + id?: unknown; + title?: unknown; + format?: unknown; + episodes?: unknown; + startDate?: unknown; + coverImage?: unknown; + description?: unknown; +} + +interface AnilistTitle { + romaji?: unknown; + english?: unknown; + native?: unknown; +} + +interface AnilistCoverImage { + extraLarge?: unknown; + large?: unknown; +} + +interface AnilistFuzzyDate { + year?: unknown; + month?: unknown; + day?: unknown; +} + +const ANILIST_FORMAT_MAP: Record = { + TV: 'series', + TV_SHORT: 'series', + MOVIE: 'movie', + OVA: 'ova', + ONA: 'ona', +}; + +const ANILIST_SEARCH_QUERY = ` +query ($search: String) { + Page(page: 1, perPage: 8) { + media(search: $search, type: ANIME, isAdult: false, sort: POPULARITY_DESC) { + id + title { romaji english native } + format + episodes + startDate { year month day } + coverImage { extraLarge large } + description + } + } +} +`; + +const ANILIST_DETAILS_QUERY = ` +query ($id: Int) { + Media(id: $id, type: ANIME) { + id + format + episodes + startDate { year month day } + } +} +`; + +@Injectable({ + providedIn: 'root', +}) +export class AnilistSuggestionService { + private readonly http = inject(HttpClient); + + search(query: string): Observable { + const trimmedQuery = query.trim(); + if (trimmedQuery.length < 2) { + return of([]); + } + + return this.http + .post( + 'https://graphql.anilist.co', + { + query: ANILIST_SEARCH_QUERY, + variables: { search: trimmedQuery }, + }, + { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }, + ) + .pipe(map((response) => this.mapResults(response.data?.Page?.media ?? []))); + } + + getAnimeDetails(anilistId: number): Observable { + if (!Number.isInteger(anilistId) || anilistId < 1) { + return of(null); + } + + return this.http + .post( + 'https://graphql.anilist.co', + { + query: ANILIST_DETAILS_QUERY, + variables: { id: anilistId }, + }, + { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }, + ) + .pipe(map((response) => this.mapDetails(response.data?.Media))); + } + + private mapResults(results: unknown[]): Suggestion[] { + return results + .map((result) => this.mapResult(result)) + .filter((s): s is Suggestion => s !== null) + .slice(0, 8); + } + + private mapResult(result: unknown): Suggestion | null { + if (!result || typeof result !== 'object') { + return null; + } + + const candidate = result as AnilistMedia; + if (typeof candidate.id !== 'number') { + return null; + } + + const type = this.mapType(candidate.format); + if (!type) { + return null; + } + + const title = this.resolveTitle(candidate.title); + if (!title) { + return null; + } + + return { + id: candidate.id, + source: 'anilist', + title, + type, + year: this.extractYear(candidate.startDate), + overview: this.extractOverview(candidate.description), + posterUrl: this.extractImageUrl(candidate.coverImage), + }; + } + + private mapDetails(data: unknown): SeriesDetails | null { + if (!data || typeof data !== 'object') { + return null; + } + + const candidate = data as AnilistMedia; + const season = this.buildSeason(candidate); + return { seasons: season ? [season] : [] }; + } + + private buildSeason(candidate: AnilistMedia) { + const totalEpisodes = + typeof candidate.episodes === 'number' && candidate.episodes >= 1 + ? candidate.episodes + : undefined; + + const firstEpisodeAirDate = this.extractDate(candidate.startDate); + + if (totalEpisodes === undefined && !firstEpisodeAirDate) { + return null; + } + + return { + seasonNumber: 1, + totalEpisodes, + firstEpisodeAirDate, + }; + } + + private mapType(rawFormat: unknown): ItemType | null { + if (typeof rawFormat !== 'string') { + return null; + } + return ANILIST_FORMAT_MAP[rawFormat] ?? null; + } + + private resolveTitle(title: unknown): string | null { + if (!title || typeof title !== 'object') { + return null; + } + const candidate = title as AnilistTitle; + if (typeof candidate.romaji === 'string' && candidate.romaji.trim()) { + return candidate.romaji.trim(); + } + if (typeof candidate.english === 'string' && candidate.english.trim()) { + return candidate.english.trim(); + } + if (typeof candidate.native === 'string' && candidate.native.trim()) { + return candidate.native.trim(); + } + return null; + } + + private extractYear(startDate: unknown): string | undefined { + if (!startDate || typeof startDate !== 'object') { + return undefined; + } + const candidate = startDate as AnilistFuzzyDate; + if (typeof candidate.year === 'number' && candidate.year >= 1000) { + return String(candidate.year); + } + const date = this.extractDate(startDate); + return date ? date.slice(0, 4) : undefined; + } + + private extractDate(startDate: unknown): string | undefined { + if (!startDate || typeof startDate !== 'object') { + return undefined; + } + const candidate = startDate as AnilistFuzzyDate; + if ( + typeof candidate.year !== 'number' || + typeof candidate.month !== 'number' || + typeof candidate.day !== 'number' + ) { + return undefined; + } + if ( + !Number.isInteger(candidate.year) || + !Number.isInteger(candidate.month) || + !Number.isInteger(candidate.day) + ) { + return undefined; + } + if (candidate.month < 1 || candidate.month > 12 || candidate.day < 1 || candidate.day > 31) { + return undefined; + } + const month = String(candidate.month).padStart(2, '0'); + const day = String(candidate.day).padStart(2, '0'); + return `${candidate.year}-${month}-${day}`; + } + + private extractImageUrl(coverImage: unknown): string | undefined { + if (!coverImage || typeof coverImage !== 'object') { + return undefined; + } + const candidate = coverImage as AnilistCoverImage; + if (typeof candidate.extraLarge === 'string' && candidate.extraLarge.trim()) { + return candidate.extraLarge.trim(); + } + if (typeof candidate.large === 'string' && candidate.large.trim()) { + return candidate.large.trim(); + } + return undefined; + } + + private extractOverview(description: unknown): string | undefined { + if (typeof description !== 'string' || !description.trim()) { + return undefined; + } + const stripped = this.stripHtml(description).trim(); + return stripped ? stripped : undefined; + } + + private stripHtml(html: string): string { + return html + .replace(/<[^>]*>/g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); + } +} diff --git a/src/app/services/suggestion-search.service.ts b/src/app/services/suggestion-search.service.ts index 710fd60..59dc4fb 100644 --- a/src/app/services/suggestion-search.service.ts +++ b/src/app/services/suggestion-search.service.ts @@ -1,6 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { catchError, forkJoin, map, Observable, of } from 'rxjs'; import { Suggestion } from '../models/suggestion.model'; +import { AnilistSuggestionService } from './anilist-suggestion.service'; import { JikanSuggestionService } from './jikan-suggestion.service'; import { TmdbSuggestionService } from './tmdb-suggestion.service'; @@ -10,6 +11,7 @@ import { TmdbSuggestionService } from './tmdb-suggestion.service'; export class SuggestionSearchService { private readonly tmdb = inject(TmdbSuggestionService); private readonly jikan = inject(JikanSuggestionService); + private readonly anilist = inject(AnilistSuggestionService); search(query: string): Observable { const trimmed = query.trim(); @@ -20,10 +22,11 @@ export class SuggestionSearchService { return forkJoin({ tmdb: this.tmdb.search(trimmed).pipe(catchError(() => of([] as Suggestion[]))), mal: this.jikan.search(trimmed).pipe(catchError(() => of([] as Suggestion[]))), + anilist: this.anilist.search(trimmed).pipe(catchError(() => of([] as Suggestion[]))), }).pipe( - map(({ tmdb, mal }) => { - const merged: Suggestion[] = [...tmdb, ...mal]; - return merged.slice(0, 10); + map(({ tmdb, mal, anilist }) => { + const merged: Suggestion[] = [...tmdb, ...mal, ...anilist]; + return merged.slice(0, 15); }), ); } From c02aa38e32a3bc0cc985cfb726567dc02c2f376c Mon Sep 17 00:00:00 2001 From: CodeWithMaBot <310216433+CodeWithMaBot@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:01:46 +0200 Subject: [PATCH 06/11] refactor: centralize suggestion limits, rename mal->jikan, simplify details - Extract SUGGESTION_MIN_QUERY_LENGTH, PER_SOURCE_LIMIT, MERGED_LIMIT, DEBOUNCE_MS to src/app/domain/suggestion.constants.ts and use across tmdb/jikan/anilist services, suggestion-search, and search-stream utils (fixes magic numbers: perPage 8, limit 8, slice 0,8 / 0,15, minLength 2, debounce 400). - Rename SuggestionSource 'mal' -> 'jikan' in suggestion.model.ts and update jikan-suggestion.service, item-form badge, specs. - Add SuggestionSearchService.getDetails(ref) facade that delegates to tmdb/jikan/anilist with catchError, and simplify AddItemComponent to inject only SuggestionSearchService (removes 3 direct service deps). - Update settings.component.ts: link AniList rate-limit docs https://anilist.gitbook.io/anilist-apiv2-docs/docs/guide/rate-limiting instead of hard-coded values, remove degraded note, add Jikan (api.jikan.moe / docs.api.jikan.moe) attribution with sfw info. --- .../add-item/add-item.component.spec.ts | 69 ++++++------------- .../components/add-item/add-item.component.ts | 20 ++---- .../item-form/item-form.component.ts | 4 +- .../poster-picker/poster-picker.component.ts | 3 +- .../components/settings/settings.component.ts | 59 +++++++++++++++- src/app/domain/suggestion.constants.ts | 4 ++ src/app/models/suggestion.model.ts | 2 +- .../services/anilist-suggestion.service.ts | 10 ++- .../services/jikan-suggestion.service.spec.ts | 10 +-- src/app/services/jikan-suggestion.service.ts | 12 ++-- src/app/services/suggestion-search.service.ts | 39 +++++++++-- src/app/services/tmdb-suggestion.service.ts | 8 ++- src/app/utils/search-stream.utils.ts | 8 ++- 13 files changed, 157 insertions(+), 91 deletions(-) create mode 100644 src/app/domain/suggestion.constants.ts diff --git a/src/app/components/add-item/add-item.component.spec.ts b/src/app/components/add-item/add-item.component.spec.ts index a810313..57de271 100644 --- a/src/app/components/add-item/add-item.component.spec.ts +++ b/src/app/components/add-item/add-item.component.spec.ts @@ -3,10 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { provideRouter, Router } from '@angular/router'; import { GroupService } from '../../services/group.service'; import { WatchListService } from '../../services/watch-list.service'; -import { AnilistSuggestionService } from '../../services/anilist-suggestion.service'; -import { JikanSuggestionService } from '../../services/jikan-suggestion.service'; import { SuggestionSearchService } from '../../services/suggestion-search.service'; -import { TmdbSuggestionService } from '../../services/tmdb-suggestion.service'; import { Item } from '../../models/item.model'; import { Suggestion } from '../../models/suggestion.model'; import { AddItemComponent } from './add-item.component'; @@ -61,27 +58,7 @@ describe('AddItemComponent', () => { provide: SuggestionSearchService, useValue: { search: vi.fn(() => of([])), - }, - }, - { - provide: TmdbSuggestionService, - useValue: { - search: vi.fn(() => of([])), - getSeriesDetails: vi.fn(() => of(null)), - }, - }, - { - provide: JikanSuggestionService, - useValue: { - search: vi.fn(() => of([])), - getAnimeDetails: vi.fn(() => of(null)), - }, - }, - { - provide: AnilistSuggestionService, - useValue: { - search: vi.fn(() => of([])), - getAnimeDetails: vi.fn(() => of(null)), + getDetails: vi.fn(() => of(null)), }, }, ], @@ -142,8 +119,8 @@ describe('AddItemComponent', () => { }); it('autofills seasons after selecting a TMDB series suggestion', () => { - const tmdbSuggestionService = TestBed.inject(TmdbSuggestionService); - vi.mocked(tmdbSuggestionService.getSeriesDetails).mockReturnValue( + const suggestionSearchService = TestBed.inject(SuggestionSearchService); + vi.mocked(suggestionSearchService.getDetails).mockReturnValue( of({ seasons: [ { @@ -166,7 +143,7 @@ describe('AddItemComponent', () => { }), ); - expect(tmdbSuggestionService.getSeriesDetails).toHaveBeenCalledWith(1396); + expect(suggestionSearchService.getDetails).toHaveBeenCalledWith({ source: 'tmdb', id: 1396 }); expect(fixture.componentInstance.autofillPatch()).toEqual({ id: 1, value: { @@ -181,9 +158,9 @@ describe('AddItemComponent', () => { }); }); - it('autofills seasons after selecting a MAL OVA suggestion', () => { - const jikanSuggestionService = TestBed.inject(JikanSuggestionService); - vi.mocked(jikanSuggestionService.getAnimeDetails).mockReturnValue( + it('autofills seasons after selecting a Jikan OVA suggestion', () => { + const suggestionSearchService = TestBed.inject(SuggestionSearchService); + vi.mocked(suggestionSearchService.getDetails).mockReturnValue( of({ seasons: [ { @@ -199,14 +176,14 @@ describe('AddItemComponent', () => { fixture.componentInstance.onSuggestionSelected( createSuggestion({ id: 999, - source: 'mal', + source: 'jikan', title: 'OVA Title', type: 'ova', year: '2020', }), ); - expect(jikanSuggestionService.getAnimeDetails).toHaveBeenCalledWith(999); + expect(suggestionSearchService.getDetails).toHaveBeenCalledWith({ source: 'jikan', id: 999 }); expect(fixture.componentInstance.autofillPatch()).toEqual({ id: 1, value: { @@ -222,9 +199,7 @@ describe('AddItemComponent', () => { }); it('does not fetch season details for movie suggestions', () => { - const tmdbSuggestionService = TestBed.inject(TmdbSuggestionService); - const jikanSuggestionService = TestBed.inject(JikanSuggestionService); - const anilistSuggestionService = TestBed.inject(AnilistSuggestionService); + const suggestionSearchService = TestBed.inject(SuggestionSearchService); const fixture = TestBed.createComponent(AddItemComponent); fixture.componentInstance.onSuggestionSelected( @@ -237,16 +212,14 @@ describe('AddItemComponent', () => { }), ); - expect(tmdbSuggestionService.getSeriesDetails).not.toHaveBeenCalled(); - expect(jikanSuggestionService.getAnimeDetails).not.toHaveBeenCalled(); - expect(anilistSuggestionService.getAnimeDetails).not.toHaveBeenCalled(); + expect(suggestionSearchService.getDetails).not.toHaveBeenCalled(); expect(fixture.componentInstance.autofillPatch()).toBeNull(); }); it('does not apply stale TMDB details after the title changes', () => { - const tmdbSuggestionService = TestBed.inject(TmdbSuggestionService); + const suggestionSearchService = TestBed.inject(SuggestionSearchService); const details = new Subject<{ seasons: { seasonNumber: number; totalEpisodes: number }[] }>(); - vi.mocked(tmdbSuggestionService.getSeriesDetails).mockReturnValue(details.asObservable()); + vi.mocked(suggestionSearchService.getDetails).mockReturnValue(details.asObservable()); const fixture = TestBed.createComponent(AddItemComponent); fixture.componentInstance.onSuggestionSelected( @@ -266,9 +239,9 @@ describe('AddItemComponent', () => { }); it('does not apply stale poster from a series when the title changes before details resolve', () => { - const tmdbSuggestionService = TestBed.inject(TmdbSuggestionService); + const suggestionSearchService = TestBed.inject(SuggestionSearchService); const details = new Subject<{ seasons: { seasonNumber: number; totalEpisodes: number }[] }>(); - vi.mocked(tmdbSuggestionService.getSeriesDetails).mockReturnValue(details.asObservable()); + vi.mocked(suggestionSearchService.getDetails).mockReturnValue(details.asObservable()); const fixture = TestBed.createComponent(AddItemComponent); fixture.componentInstance.onSuggestionSelected( @@ -294,9 +267,9 @@ describe('AddItemComponent', () => { }); it('does not apply stale poster from a series when a movie is selected before details resolve', () => { - const tmdbSuggestionService = TestBed.inject(TmdbSuggestionService); + const suggestionSearchService = TestBed.inject(SuggestionSearchService); const details = new Subject<{ seasons: { seasonNumber: number; totalEpisodes: number }[] }>(); - vi.mocked(tmdbSuggestionService.getSeriesDetails).mockReturnValue(details.asObservable()); + vi.mocked(suggestionSearchService.getDetails).mockReturnValue(details.asObservable()); const fixture = TestBed.createComponent(AddItemComponent); fixture.componentInstance.onSuggestionSelected( @@ -330,9 +303,9 @@ describe('AddItemComponent', () => { }); it('cancels the previous TMDB details request when another series is selected', () => { - const tmdbSuggestionService = TestBed.inject(TmdbSuggestionService); + const suggestionSearchService = TestBed.inject(SuggestionSearchService); let firstRequestUnsubscribed = false; - vi.mocked(tmdbSuggestionService.getSeriesDetails) + vi.mocked(suggestionSearchService.getDetails) .mockReturnValueOnce( new Observable((subscriber) => { subscriber.next({ @@ -368,8 +341,8 @@ describe('AddItemComponent', () => { ); expect(firstRequestUnsubscribed).toBe(true); - expect(tmdbSuggestionService.getSeriesDetails).toHaveBeenCalledWith(1396); - expect(tmdbSuggestionService.getSeriesDetails).toHaveBeenCalledWith(66732); + expect(suggestionSearchService.getDetails).toHaveBeenCalledWith({ source: 'tmdb', id: 1396 }); + expect(suggestionSearchService.getDetails).toHaveBeenCalledWith({ source: 'tmdb', id: 66732 }); expect(fixture.componentInstance.autofillPatch()).toEqual({ id: 2, value: { diff --git a/src/app/components/add-item/add-item.component.ts b/src/app/components/add-item/add-item.component.ts index f1c1ded..eb4380a 100644 --- a/src/app/components/add-item/add-item.component.ts +++ b/src/app/components/add-item/add-item.component.ts @@ -1,12 +1,9 @@ import { Component, DestroyRef, ViewChild, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; -import { catchError, of, Subject, switchMap } from 'rxjs'; +import { of, Subject, switchMap } from 'rxjs'; import { WatchListService } from '../../services/watch-list.service'; import { GroupService } from '../../services/group.service'; -import { AnilistSuggestionService } from '../../services/anilist-suggestion.service'; -import { JikanSuggestionService } from '../../services/jikan-suggestion.service'; -import { TmdbSuggestionService } from '../../services/tmdb-suggestion.service'; import { SuggestionSearchService } from '../../services/suggestion-search.service'; import { ItemFormComponent } from '../item-form/item-form.component'; import { @@ -17,6 +14,7 @@ import { import { Suggestion, SuggestionSource } from '../../models/suggestion.model'; import { createSearchStream } from '../../utils/search-stream.utils'; import { isEpisodicType } from '../../domain/item.constants'; +import { SUGGESTION_DEBOUNCE_MS } from '../../domain/suggestion.constants'; interface SelectedSuggestionRef { source: SuggestionSource; @@ -53,9 +51,6 @@ export class AddItemComponent { @ViewChild(ItemFormComponent) private form?: ItemFormComponent; private watchListService = inject(WatchListService); private groupService = inject(GroupService); - private tmdbSuggestionService = inject(TmdbSuggestionService); - private jikanSuggestionService = inject(JikanSuggestionService); - private anilistSuggestionService = inject(AnilistSuggestionService); private suggestionSearchService = inject(SuggestionSearchService); private router = inject(Router); private destroyRef = inject(DestroyRef); @@ -63,7 +58,7 @@ export class AddItemComponent { (query) => this.suggestionSearchService.search(query), 'Suggestions are unavailable.', { - debounceMs: 400, + debounceMs: SUGGESTION_DEBOUNCE_MS, distinct: true, shouldSkip: () => { const skip = this.skipNextSearch; @@ -111,14 +106,7 @@ export class AddItemComponent { return of(null); } - const details$ = - ref.source === 'mal' - ? this.jikanSuggestionService.getAnimeDetails(ref.id) - : ref.source === 'anilist' - ? this.anilistSuggestionService.getAnimeDetails(ref.id) - : this.tmdbSuggestionService.getSeriesDetails(ref.id); - - return details$.pipe(catchError(() => of(null))); + return this.suggestionSearchService.getDetails(ref); }), takeUntilDestroyed(this.destroyRef), ) diff --git a/src/app/components/item-form/item-form.component.ts b/src/app/components/item-form/item-form.component.ts index 3b16c21..648029d 100644 --- a/src/app/components/item-form/item-form.component.ts +++ b/src/app/components/item-form/item-form.component.ts @@ -95,8 +95,8 @@ export interface ItemFormAutofillPatch { class="block text-sm text-light-font-secondary dark:text-dark-font-secondary" >{{ itemTypeLabels[suggestion.type] }} · {{ - suggestion.source === 'mal' - ? 'MAL' + suggestion.source === 'jikan' + ? 'Jikan' : suggestion.source === 'anilist' ? 'AniList' : 'TMDB' diff --git a/src/app/components/poster-picker/poster-picker.component.ts b/src/app/components/poster-picker/poster-picker.component.ts index 111d4ef..cc35f30 100644 --- a/src/app/components/poster-picker/poster-picker.component.ts +++ b/src/app/components/poster-picker/poster-picker.component.ts @@ -6,6 +6,7 @@ import { SuggestionSearchService } from '../../services/suggestion-search.servic import { ImageStorageService } from '../../services/image-storage.service'; import { getPlaceholderUrl } from '../../utils/tmdb-image.utils'; import { createSearchStream } from '../../utils/search-stream.utils'; +import { SUGGESTION_DEBOUNCE_MS } from '../../domain/suggestion.constants'; @Component({ selector: 'app-poster-picker', @@ -143,7 +144,7 @@ export class PosterPickerComponent { (query) => this.suggestionSearchService.search(query), 'Search unavailable.', { - debounceMs: 400, + debounceMs: SUGGESTION_DEBOUNCE_MS, onLoadingChange: (loading) => this.posterSuggestionsLoading.set(loading), onError: (message) => this.posterSuggestionsError.set(message), }, diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index 5b1865a..27ca00c 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -106,7 +106,64 @@ import { environment } from '../../../environments/environment'; Anime suggestions (TV, Movie, OVA, ONA) are fetched from AniList’s public GraphQL API with isAdult:false filtering and no authentication required. - Rate limit: ~90 requests/min (currently 30/min degraded). + See + rate limiting docs + for limits. +

+

+ + +
+

+ Jikan (MyAnimeList) +

+
+ + Jikan.moe + + via + + MyAnimeList.net + +

+ This product uses the + Jikan API + (api.jikan.moe) which provides data from MyAnimeList but is not endorsed or certified by + MyAnimeList or Jikan. +

+

+ Anime suggestions (TV, Movie, OVA, ONA) are fetched from Jikan’s public REST API with + sfw:true filtering and no authentication required. See + Jikan docs + for limits (3 requests/second).

diff --git a/src/app/domain/suggestion.constants.ts b/src/app/domain/suggestion.constants.ts new file mode 100644 index 0000000..1912a06 --- /dev/null +++ b/src/app/domain/suggestion.constants.ts @@ -0,0 +1,4 @@ +export const SUGGESTION_MIN_QUERY_LENGTH = 2; +export const SUGGESTION_PER_SOURCE_LIMIT = 8; +export const SUGGESTION_MERGED_LIMIT = 15; +export const SUGGESTION_DEBOUNCE_MS = 400; diff --git a/src/app/models/suggestion.model.ts b/src/app/models/suggestion.model.ts index 73a4268..f3d10f9 100644 --- a/src/app/models/suggestion.model.ts +++ b/src/app/models/suggestion.model.ts @@ -1,6 +1,6 @@ import { ItemType, SeasonInfo } from './item.model'; -export type SuggestionSource = 'tmdb' | 'mal' | 'anilist'; +export type SuggestionSource = 'tmdb' | 'jikan' | 'anilist'; export interface Suggestion { id: number; diff --git a/src/app/services/anilist-suggestion.service.ts b/src/app/services/anilist-suggestion.service.ts index 17cb3e8..4211838 100644 --- a/src/app/services/anilist-suggestion.service.ts +++ b/src/app/services/anilist-suggestion.service.ts @@ -3,6 +3,10 @@ import { Injectable, inject } from '@angular/core'; import { map, Observable, of } from 'rxjs'; import { SeriesDetails, Suggestion } from '../models/suggestion.model'; import { ItemType } from '../models/item.model'; +import { + SUGGESTION_MIN_QUERY_LENGTH, + SUGGESTION_PER_SOURCE_LIMIT, +} from '../domain/suggestion.constants'; interface AnilistSearchResponse { data?: { @@ -55,7 +59,7 @@ const ANILIST_FORMAT_MAP: Record = { const ANILIST_SEARCH_QUERY = ` query ($search: String) { - Page(page: 1, perPage: 8) { + Page(page: 1, perPage: ${SUGGESTION_PER_SOURCE_LIMIT}) { media(search: $search, type: ANIME, isAdult: false, sort: POPULARITY_DESC) { id title { romaji english native } @@ -88,7 +92,7 @@ export class AnilistSuggestionService { search(query: string): Observable { const trimmedQuery = query.trim(); - if (trimmedQuery.length < 2) { + if (trimmedQuery.length < SUGGESTION_MIN_QUERY_LENGTH) { return of([]); } @@ -135,7 +139,7 @@ export class AnilistSuggestionService { return results .map((result) => this.mapResult(result)) .filter((s): s is Suggestion => s !== null) - .slice(0, 8); + .slice(0, SUGGESTION_PER_SOURCE_LIMIT); } private mapResult(result: unknown): Suggestion | null { diff --git a/src/app/services/jikan-suggestion.service.spec.ts b/src/app/services/jikan-suggestion.service.spec.ts index c154abc..38531d8 100644 --- a/src/app/services/jikan-suggestion.service.spec.ts +++ b/src/app/services/jikan-suggestion.service.spec.ts @@ -14,7 +14,7 @@ describe('JikanSuggestionService', () => { TestBed.inject(HttpTestingController).verify(); }); - it('searches MAL and maps TV, movie, OVA, and ONA results', () => { + it('searches Jikan and maps TV, movie, OVA, and ONA results', () => { const service = TestBed.inject(JikanSuggestionService); const http = TestBed.inject(HttpTestingController); let suggestions: unknown; @@ -81,7 +81,7 @@ describe('JikanSuggestionService', () => { expect(suggestions).toEqual([ { id: 1, - source: 'mal', + source: 'jikan', title: 'Cowboy Bebop', type: 'series', year: '1998', @@ -90,7 +90,7 @@ describe('JikanSuggestionService', () => { }, { id: 5, - source: 'mal', + source: 'jikan', title: 'Cowboy Bebop: The Movie', type: 'movie', year: '2001', @@ -99,7 +99,7 @@ describe('JikanSuggestionService', () => { }, { id: 6, - source: 'mal', + source: 'jikan', title: 'OVA Title', type: 'ova', year: '2020', @@ -108,7 +108,7 @@ describe('JikanSuggestionService', () => { }, { id: 7, - source: 'mal', + source: 'jikan', title: 'ONA Title', type: 'ona', year: '2021', diff --git a/src/app/services/jikan-suggestion.service.ts b/src/app/services/jikan-suggestion.service.ts index c096461..fb16a0c 100644 --- a/src/app/services/jikan-suggestion.service.ts +++ b/src/app/services/jikan-suggestion.service.ts @@ -3,6 +3,10 @@ import { Injectable, inject } from '@angular/core'; import { map, Observable, of } from 'rxjs'; import { SeriesDetails, Suggestion } from '../models/suggestion.model'; import { ItemType } from '../models/item.model'; +import { + SUGGESTION_MIN_QUERY_LENGTH, + SUGGESTION_PER_SOURCE_LIMIT, +} from '../domain/suggestion.constants'; interface JikanSearchResponse { data?: unknown[]; @@ -51,13 +55,13 @@ export class JikanSuggestionService { search(query: string): Observable { const trimmedQuery = query.trim(); - if (trimmedQuery.length < 2) { + if (trimmedQuery.length < SUGGESTION_MIN_QUERY_LENGTH) { return of([]); } const params = new HttpParams() .set('q', trimmedQuery) - .set('limit', '8') + .set('limit', String(SUGGESTION_PER_SOURCE_LIMIT)) .set('sfw', 'true') .set('order_by', 'popularity') .set('sort', 'asc'); @@ -81,7 +85,7 @@ export class JikanSuggestionService { return results .map((result) => this.mapResult(result)) .filter((s): s is Suggestion => s !== null) - .slice(0, 8); + .slice(0, SUGGESTION_PER_SOURCE_LIMIT); } private mapResult(result: unknown): Suggestion | null { @@ -106,7 +110,7 @@ export class JikanSuggestionService { return { id: candidate.mal_id, - source: 'mal', + source: 'jikan', title, type, year: this.extractYear(candidate.aired), diff --git a/src/app/services/suggestion-search.service.ts b/src/app/services/suggestion-search.service.ts index 59dc4fb..32b8054 100644 --- a/src/app/services/suggestion-search.service.ts +++ b/src/app/services/suggestion-search.service.ts @@ -1,9 +1,13 @@ import { Injectable, inject } from '@angular/core'; import { catchError, forkJoin, map, Observable, of } from 'rxjs'; -import { Suggestion } from '../models/suggestion.model'; +import { SeriesDetails, Suggestion, SuggestionSource } from '../models/suggestion.model'; import { AnilistSuggestionService } from './anilist-suggestion.service'; import { JikanSuggestionService } from './jikan-suggestion.service'; import { TmdbSuggestionService } from './tmdb-suggestion.service'; +import { + SUGGESTION_MERGED_LIMIT, + SUGGESTION_MIN_QUERY_LENGTH, +} from '../domain/suggestion.constants'; @Injectable({ providedIn: 'root', @@ -15,19 +19,42 @@ export class SuggestionSearchService { search(query: string): Observable { const trimmed = query.trim(); - if (trimmed.length < 2) { + if (trimmed.length < SUGGESTION_MIN_QUERY_LENGTH) { return of([]); } return forkJoin({ tmdb: this.tmdb.search(trimmed).pipe(catchError(() => of([] as Suggestion[]))), - mal: this.jikan.search(trimmed).pipe(catchError(() => of([] as Suggestion[]))), + jikan: this.jikan.search(trimmed).pipe(catchError(() => of([] as Suggestion[]))), anilist: this.anilist.search(trimmed).pipe(catchError(() => of([] as Suggestion[]))), }).pipe( - map(({ tmdb, mal, anilist }) => { - const merged: Suggestion[] = [...tmdb, ...mal, ...anilist]; - return merged.slice(0, 15); + map(({ tmdb, jikan, anilist }) => { + const merged: Suggestion[] = [...tmdb, ...jikan, ...anilist]; + return merged.slice(0, SUGGESTION_MERGED_LIMIT); }), ); } + + getDetails(ref: Pick): Observable { + if (!Number.isInteger(ref.id) || ref.id < 1) { + return of(null); + } + + let details$: Observable; + switch (ref.source as SuggestionSource) { + case 'jikan': + details$ = this.jikan.getAnimeDetails(ref.id); + break; + case 'anilist': + details$ = this.anilist.getAnimeDetails(ref.id); + break; + case 'tmdb': + details$ = this.tmdb.getSeriesDetails(ref.id); + break; + default: + return of(null); + } + + return details$.pipe(catchError(() => of(null))); + } } diff --git a/src/app/services/tmdb-suggestion.service.ts b/src/app/services/tmdb-suggestion.service.ts index b25ee41..647d7f5 100644 --- a/src/app/services/tmdb-suggestion.service.ts +++ b/src/app/services/tmdb-suggestion.service.ts @@ -4,6 +4,10 @@ import { map, Observable, of } from 'rxjs'; import { SeriesDetails, Suggestion } from '../models/suggestion.model'; import { TmdbSettingsService } from './tmdb-settings.service'; import { getPosterUrl } from '../utils/tmdb-image.utils'; +import { + SUGGESTION_MIN_QUERY_LENGTH, + SUGGESTION_PER_SOURCE_LIMIT, +} from '../domain/suggestion.constants'; interface TmdbSearchResponse { results?: unknown[]; @@ -42,7 +46,7 @@ export class TmdbSuggestionService { const trimmedQuery = query.trim(); const requestOptions = this.createRequestOptions(); - if (trimmedQuery.length < 2 || !requestOptions) { + if (trimmedQuery.length < SUGGESTION_MIN_QUERY_LENGTH || !requestOptions) { return of([]); } @@ -112,7 +116,7 @@ export class TmdbSuggestionService { return results .map((result) => this.mapResult(result)) .filter((suggestion): suggestion is Suggestion => suggestion !== null) - .slice(0, 8); + .slice(0, SUGGESTION_PER_SOURCE_LIMIT); } private mapResult(result: unknown): Suggestion | null { diff --git a/src/app/utils/search-stream.utils.ts b/src/app/utils/search-stream.utils.ts index 774fa0e..c12ddb9 100644 --- a/src/app/utils/search-stream.utils.ts +++ b/src/app/utils/search-stream.utils.ts @@ -9,6 +9,10 @@ import { finalize, } from 'rxjs'; import { Suggestion } from '../models/suggestion.model'; +import { + SUGGESTION_DEBOUNCE_MS, + SUGGESTION_MIN_QUERY_LENGTH, +} from '../domain/suggestion.constants'; export interface SearchStreamOptions { debounceMs?: number; @@ -29,8 +33,8 @@ export function createSearchStream( errorMessage: string, options: SearchStreamOptions = {}, ): SearchStream { - const debounceMs = options.debounceMs ?? 300; - const minLength = options.minLength ?? 2; + const debounceMs = options.debounceMs ?? SUGGESTION_DEBOUNCE_MS; + const minLength = options.minLength ?? SUGGESTION_MIN_QUERY_LENGTH; const query = new Subject(); let stream = query.pipe(debounceTime(debounceMs)); From 06a77aeb2c91807b8226998110d19f3d34bca6a4 Mon Sep 17 00:00:00 2001 From: CodeWithMaBot <310216433+CodeWithMaBot@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:06:23 +0200 Subject: [PATCH 07/11] docs: add test code coverage command to AGENTS.md --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 2143705..77df3b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,3 +13,4 @@ The main function is to show me what tv series or movie I should watch next. - This project uses bun. Examples: `bun run lint`, `bun run build`, `bun run test --no-watch`. - Run a single spec file: `bun run test -- --include="src/app/utils/form.utils.spec.ts" --no-watch` - Run a single test by name (regex): `bun run test -- --filter="toPositiveNumber" --no-watch` +- To get test code coverage run: `bun run test -- --coverage --no-watch` From 9085c3f358e378372d1c24ec7b3ce105cc070a99 Mon Sep 17 00:00:00 2001 From: CodeWithMaBot <310216433+CodeWithMaBot@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:23:21 +0200 Subject: [PATCH 08/11] test: improve coverage for core services and components Raise overall coverage from 76% to 89% statements and 78% to 91% lines (272 tests). Add specs for item-card, image-storage, suggestion-search and utils (progress, status, tmdb-image). Expand home, round-robin, poster-picker and time-ago to cover critical branches. --- .../components/home/home.component.spec.ts | 255 +++++++++++++- .../item-card/item-card.component.spec.ts | 189 ++++++++++ .../poster-picker.component.spec.ts | 291 ++++++++++++++++ .../time-ago/time-ago.component.spec.ts | 77 ++++ .../services/image-storage.service.spec.ts | 329 ++++++++++++++++++ src/app/services/round-robin.service.spec.ts | 215 ++++++++++++ .../suggestion-search.service.spec.ts | 146 ++++++++ src/app/utils/progress.utils.spec.ts | 147 ++++++++ src/app/utils/status.utils.spec.ts | 31 ++ src/app/utils/tmdb-image.utils.spec.ts | 31 ++ 10 files changed, 1699 insertions(+), 12 deletions(-) create mode 100644 src/app/components/item-card/item-card.component.spec.ts create mode 100644 src/app/components/time-ago/time-ago.component.spec.ts create mode 100644 src/app/services/image-storage.service.spec.ts create mode 100644 src/app/services/suggestion-search.service.spec.ts create mode 100644 src/app/utils/progress.utils.spec.ts create mode 100644 src/app/utils/status.utils.spec.ts create mode 100644 src/app/utils/tmdb-image.utils.spec.ts diff --git a/src/app/components/home/home.component.spec.ts b/src/app/components/home/home.component.spec.ts index 7e59dc5..dd6ede1 100644 --- a/src/app/components/home/home.component.spec.ts +++ b/src/app/components/home/home.component.spec.ts @@ -5,8 +5,20 @@ import { vi } from 'vitest'; import { Item } from '../../models/item.model'; import { RoundRobinService } from '../../services/round-robin.service'; import { WatchListService } from '../../services/watch-list.service'; +import { ImageStorageService } from '../../services/image-storage.service'; import { HomeComponent } from './home.component'; +function createItem(overrides: Partial & Pick): Item { + return { + type: 'series', + groupId: 'ungrouped', + status: 'not-started', + watchHistory: [], + createdAt: '2026-05-01T10:00:00.000Z', + ...overrides, + } as Item; +} + describe('HomeComponent', () => { const items: Item[] = [ { @@ -29,25 +41,50 @@ describe('HomeComponent', () => { }, ]; - beforeEach(() => { + function setup({ + watchItems = items, + inProgressSeries = [], + nextSeries = null, + nextMovie = null, + }: { + watchItems?: Item[]; + inProgressSeries?: Item[]; + nextSeries?: Item | null; + nextMovie?: Item | null; + } = {}) { + TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ provideRouter([]), { provide: RoundRobinService, - useValue: { nextSeries: signal(null), nextMovie: signal(null) }, + useValue: { nextSeries: signal(nextSeries), nextMovie: signal(nextMovie) }, }, { provide: WatchListService, useValue: { - items: signal(items), - inProgressSeries: signal([]), + items: signal(watchItems), + inProgressSeries: signal(inProgressSeries), markStarted: vi.fn(), markDropped: vi.fn(), + markWatched: vi.fn(), + markCompleted: vi.fn(), + markPaused: vi.fn(), + }, + }, + { + provide: ImageStorageService, + useValue: { + version: signal(0).asReadonly(), + getUrl: vi.fn(() => Promise.resolve(null)), }, }, ], }); + } + + beforeEach(() => { + setup(); }); it('shows paused movies separately from backlog', async () => { @@ -78,14 +115,7 @@ describe('HomeComponent', () => { }); it('shows an empty state when no items are paused', async () => { - TestBed.overrideProvider(WatchListService, { - useValue: { - items: signal([]), - inProgressSeries: signal([]), - markStarted: vi.fn(), - markDropped: vi.fn(), - }, - }); + setup({ watchItems: [] }); const fixture = TestBed.createComponent(HomeComponent); fixture.detectChanges(); await fixture.whenStable(); @@ -93,4 +123,205 @@ describe('HomeComponent', () => { expect(fixture.nativeElement.textContent).toContain('No items paused'); }); + + it('computes backlogItems sorted newest first', () => { + const backlog = [ + createItem({ + id: 'a', + title: 'A', + status: 'not-started', + createdAt: '2026-01-01T00:00:00.000Z', + }), + createItem({ + id: 'b', + title: 'B', + status: 'not-started', + createdAt: '2026-03-01T00:00:00.000Z', + }), + createItem({ id: 'c', title: 'C', status: 'paused', createdAt: '2026-02-01T00:00:00.000Z' }), + ]; + setup({ watchItems: backlog }); + const fixture = TestBed.createComponent(HomeComponent); + fixture.detectChanges(); + expect(fixture.componentInstance.backlogItems().map((i) => i.id)).toEqual(['b', 'a']); + }); + + it('computes pausedItems sorted newest first', () => { + const paused = [ + createItem({ + id: 'p1', + title: 'P1', + status: 'paused', + createdAt: '2026-01-01T00:00:00.000Z', + }), + createItem({ + id: 'p2', + title: 'P2', + status: 'paused', + createdAt: '2026-02-01T00:00:00.000Z', + }), + ]; + setup({ watchItems: paused }); + const fixture = TestBed.createComponent(HomeComponent); + fixture.detectChanges(); + expect(fixture.componentInstance.pausedItems().map((i) => i.id)).toEqual(['p2', 'p1']); + }); + + it('shows backlog and paused empty states', async () => { + setup({ watchItems: [] }); + const fixture = TestBed.createComponent(HomeComponent); + fixture.detectChanges(); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain('No items in backlog'); + expect(fixture.nativeElement.textContent).toContain('No items paused'); + }); + + it('shows backlog items and handles start/drop backlog', () => { + setup(); + const fixture = TestBed.createComponent(HomeComponent); + const svc = TestBed.inject(WatchListService); + fixture.componentInstance.startBacklogItem('backlog-item'); + fixture.componentInstance.dropBacklogItem('backlog-item'); + expect(svc.markStarted).toHaveBeenCalledWith('backlog-item'); + expect(svc.markDropped).toHaveBeenCalledWith('backlog-item'); + }); + + it('markItem delegates to correct service methods', () => { + setup(); + const fixture = TestBed.createComponent(HomeComponent); + const svc = TestBed.inject(WatchListService) as unknown as { + markWatched: ReturnType; + markCompleted: ReturnType; + markDropped: ReturnType; + markPaused: ReturnType; + }; + const item = createItem({ id: 'x', title: 'X', status: 'in-progress' }); + + fixture.componentInstance.markItem(() => item, 'watched'); + expect(svc.markWatched).toHaveBeenCalledWith('x'); + + fixture.componentInstance.markItem(() => item, 'completed'); + expect(svc.markCompleted).toHaveBeenCalledWith('x'); + + fixture.componentInstance.markItem(() => item, 'dropped'); + expect(svc.markDropped).toHaveBeenCalledWith('x'); + + fixture.componentInstance.markItem(() => item, 'paused'); + expect(svc.markPaused).toHaveBeenCalledWith('x'); + }); + + it('markItem does nothing when getter returns null', () => { + setup(); + const fixture = TestBed.createComponent(HomeComponent); + const svc = TestBed.inject(WatchListService); + fixture.componentInstance.markItem(() => null, 'watched'); + fixture.componentInstance.markItem(() => undefined, 'completed'); + expect(svc.markWatched).not.toHaveBeenCalled(); + expect(svc.markCompleted).not.toHaveBeenCalled(); + }); + + it('renders nextSeries and nextMovie when provided', async () => { + const series = createItem({ + id: 's1', + title: 'Series 1', + type: 'series', + status: 'in-progress', + posterId: 'p1', + }); + const movie = createItem({ id: 'm1', title: 'Movie 1', type: 'movie', status: 'in-progress' }); + setup({ + nextSeries: series, + nextMovie: movie, + watchItems: [series, movie], + inProgressSeries: [series], + }); + const fixture = TestBed.createComponent(HomeComponent); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Series 1'); + expect(fixture.nativeElement.textContent).toContain('Movie 1'); + }); + + it('shows empty states for nextSeries/nextMovie', async () => { + // No series in list + setup({ watchItems: [], inProgressSeries: [], nextSeries: null, nextMovie: null }); + let fixture = TestBed.createComponent(HomeComponent); + fixture.detectChanges(); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain('No series in your watch list'); + + // Has series but none in progress + const seriesNotStarted = createItem({ + id: 's1', + title: 'S', + type: 'series', + status: 'not-started', + }); + setup({ + watchItems: [seriesNotStarted], + inProgressSeries: [], + nextSeries: null, + nextMovie: null, + }); + fixture = TestBed.createComponent(HomeComponent); + fixture.detectChanges(); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain('No series currently being watched'); + + // Has in-progress but not watchable (all future) + const inProg = createItem({ id: 's2', title: 'S2', type: 'series', status: 'in-progress' }); + setup({ watchItems: [inProg], inProgressSeries: [inProg], nextSeries: null, nextMovie: null }); + fixture = TestBed.createComponent(HomeComponent); + fixture.detectChanges(); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain('No aired series episodes available'); + + // Movies + setup({ watchItems: [], inProgressSeries: [], nextSeries: null, nextMovie: null }); + fixture = TestBed.createComponent(HomeComponent); + fixture.detectChanges(); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain('No movies in your watch list'); + + const movieNotStarted = createItem({ + id: 'm1', + title: 'M', + type: 'movie', + status: 'not-started', + }); + setup({ + watchItems: [movieNotStarted], + inProgressSeries: [], + nextSeries: null, + nextMovie: null, + }); + fixture = TestBed.createComponent(HomeComponent); + fixture.detectChanges(); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain('No movies currently being watched'); + }); + + it('computes hasSeries and hasMovies correctly', () => { + setup({ watchItems: [createItem({ id: '1', title: 'S', type: 'series' })] }); + let fixture = TestBed.createComponent(HomeComponent); + fixture.detectChanges(); + expect((fixture.componentInstance as unknown as { hasSeries: () => boolean }).hasSeries()).toBe( + true, + ); + + setup({ watchItems: [createItem({ id: '2', title: 'M', type: 'movie' })] }); + fixture = TestBed.createComponent(HomeComponent); + fixture.detectChanges(); + expect((fixture.componentInstance as unknown as { hasMovies: () => boolean }).hasMovies()).toBe( + true, + ); + + setup({ watchItems: [] }); + fixture = TestBed.createComponent(HomeComponent); + fixture.detectChanges(); + expect((fixture.componentInstance as unknown as { hasSeries: () => boolean }).hasSeries()).toBe( + false, + ); + }); }); diff --git a/src/app/components/item-card/item-card.component.spec.ts b/src/app/components/item-card/item-card.component.spec.ts new file mode 100644 index 0000000..660e9d3 --- /dev/null +++ b/src/app/components/item-card/item-card.component.spec.ts @@ -0,0 +1,189 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { vi } from 'vitest'; +import { ItemCardComponent } from './item-card.component'; +import { ImageStorageService } from '../../services/image-storage.service'; +import { Item } from '../../models/item.model'; + +function createItem(overrides: Partial = {}): Item { + return { + id: 'item-1', + title: 'Test Show', + type: 'series', + groupId: 'ungrouped', + status: 'in-progress', + watchHistory: [{ date: '2026-04-02T10:00:00.000Z', season: 1, episode: 1 }], + createdAt: '2026-04-01T10:00:00.000Z', + progress: { season: 1, episode: 2, seasons: [{ seasonNumber: 1, totalEpisodes: 10 }] }, + ...overrides, + }; +} + +describe('ItemCardComponent', () => { + let versionSignal: ReturnType>; + let getUrlMock: ReturnType; + + beforeEach(() => { + versionSignal = signal(0); + getUrlMock = vi.fn((id: string | undefined) => Promise.resolve(id ? `blob:${id}` : null)); + + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: ImageStorageService, + useValue: { + version: versionSignal.asReadonly(), + getUrl: getUrlMock, + }, + }, + ], + }); + }); + + it('computes placeholder, status color, progress and last watched date', async () => { + const fixture = TestBed.createComponent(ItemCardComponent); + fixture.componentRef.setInput('item', createItem({ status: 'completed' })); + fixture.detectChanges(); + await fixture.whenStable(); + + const instance = fixture.componentInstance; + expect(instance.placeholderUrl()).toContain('data:image/svg+xml;base64,'); + expect(instance.statusColorClass()).toContain('bg-status-completed'); + expect(instance.progressPercent()).toBe(100); + expect(instance.lastWatchedDate()).toBe('2026-04-02T10:00:00.000Z'); + }); + + it('loads poster on init and on version change', async () => { + const fixture = TestBed.createComponent(ItemCardComponent); + fixture.componentRef.setInput('item', createItem({ posterId: 'p1' })); + fixture.detectChanges(); + await Promise.resolve(); + await Promise.resolve(); + // allow effect to run + await new Promise((r) => setTimeout(r, 0)); + + expect(getUrlMock).toHaveBeenCalledWith('p1'); + expect(fixture.componentInstance.posterUrl()).toBe('blob:p1'); + + // change version should reload + versionSignal.set(1); + fixture.detectChanges(); + await new Promise((r) => setTimeout(r, 0)); + await Promise.resolve(); + + expect(getUrlMock).toHaveBeenCalledTimes(2); + expect(getUrlMock).toHaveBeenLastCalledWith('p1'); + }); + + it('renders poster or placeholder based on posterUrl', async () => { + const fixture = TestBed.createComponent(ItemCardComponent); + fixture.componentRef.setInput('item', createItem({ posterId: 'p1' })); + fixture.detectChanges(); + await new Promise((r) => setTimeout(r, 0)); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.componentInstance.posterUrl()).toBe('blob:p1'); + expect(fixture.nativeElement.querySelector('img')?.getAttribute('src')).toBe('blob:p1'); + }); + + it('does not set posterUrl if destroyed before load completes', async () => { + let resolveUrl!: (v: string | null) => void; + getUrlMock.mockImplementation(() => new Promise((res) => (resolveUrl = res))); + + const fixture = TestBed.createComponent(ItemCardComponent); + fixture.componentRef.setInput('item', createItem({ posterId: 'p1' })); + fixture.detectChanges(); + + fixture.destroy(); + resolveUrl('blob:p1'); + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + + expect(fixture.componentInstance.posterUrl()).toBeNull(); + }); + + it('does not set posterUrl if item posterId changed before load completes', async () => { + const resolvers = new Map void>(); + getUrlMock.mockImplementation((id: string | undefined) => { + return new Promise((res) => { + if (id) resolvers.set(id, res); + else res(null); + }); + }); + + const fixture = TestBed.createComponent(ItemCardComponent); + fixture.componentRef.setInput('item', createItem({ posterId: 'p1' })); + fixture.detectChanges(); + + // trigger second load with p2 + fixture.componentRef.setInput('item', createItem({ posterId: 'p2' })); + fixture.detectChanges(); + await new Promise((r) => setTimeout(r, 0)); + + // resolve p1 first + resolvers.get('p1')?.('blob:p1'); + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + // should still be null because p1 is stale + expect(fixture.componentInstance.posterUrl()).toBeNull(); + + // resolve p2 + resolvers.get('p2')?.('blob:p2'); + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + expect(fixture.componentInstance.posterUrl()).toBe('blob:p2'); + }); + + it('ignores stale version loads but allows fresh version load', async () => { + const resolvers: ((v: string | null) => void)[] = []; + getUrlMock.mockImplementation(() => new Promise((res) => resolvers.push(res))); + + const fixture = TestBed.createComponent(ItemCardComponent); + fixture.componentRef.setInput('item', createItem({ posterId: 'p1' })); + fixture.detectChanges(); + await new Promise((r) => setTimeout(r, 0)); + + // version changes triggers second load + versionSignal.set(99); + fixture.detectChanges(); + await new Promise((r) => setTimeout(r, 0)); + + expect(getUrlMock).toHaveBeenCalledTimes(2); + // resolve first (stale) + resolvers[0]('blob:p1'); + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + expect(fixture.componentInstance.posterUrl()).toBeNull(); + + // resolve second (fresh) + resolvers[1]('blob:p1'); + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + expect(fixture.componentInstance.posterUrl()).toBe('blob:p1'); + }); + + it('shows placeholder when item has no posterId', async () => { + getUrlMock.mockResolvedValue(null); + const fixture = TestBed.createComponent(ItemCardComponent); + fixture.componentRef.setInput('item', createItem({ posterId: undefined })); + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((r) => setTimeout(r, 0)); + expect(fixture.componentInstance.posterUrl()).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Test Show'); + }); + + it('computes progress and lastWatched for movie', () => { + const fixture = TestBed.createComponent(ItemCardComponent); + fixture.componentRef.setInput( + 'item', + createItem({ type: 'movie', status: 'not-started', progress: undefined, watchHistory: [] }), + ); + fixture.detectChanges(); + expect(fixture.componentInstance.progressPercent()).toBe(0); + expect(fixture.componentInstance.lastWatchedDate()).toBe('2026-04-01T10:00:00.000Z'); + }); +}); diff --git a/src/app/components/poster-picker/poster-picker.component.spec.ts b/src/app/components/poster-picker/poster-picker.component.spec.ts index ca61d75..1c2ac44 100644 --- a/src/app/components/poster-picker/poster-picker.component.spec.ts +++ b/src/app/components/poster-picker/poster-picker.component.spec.ts @@ -333,4 +333,295 @@ describe('PosterPickerComponent', () => { vi.useRealTimers(); } }); + + it('opens poster search without seed and with whitespace seed', async () => { + vi.useFakeTimers(); + try { + const fixture = TestBed.createComponent(PosterPickerComponent); + fixture.componentRef.setInput('searchSeed', ' '); + fixture.detectChanges(); + + fixture.componentInstance.openPosterSearch(); + expect(fixture.componentInstance.showPosterSearch()).toBe(true); + expect(fixture.componentInstance.posterSearchQuery()).toBe(''); + + // with no seed + const fixture2 = TestBed.createComponent(PosterPickerComponent); + fixture2.componentRef.setInput('searchSeed', ''); + fixture2.detectChanges(); + fixture2.componentInstance.openPosterSearch(); + expect(fixture2.componentInstance.showPosterSearch()).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('does not store poster when suggestion has no posterUrl', async () => { + const fixture = TestBed.createComponent(PosterPickerComponent); + const imageStorage = TestBed.inject(ImageStorageService); + fixture.detectChanges(); + + fixture.componentInstance.selectPosterSuggestion( + createSuggestion({ id: 1, title: 'No Poster', type: 'movie' }), + ); + + expect(imageStorage.storeUrl).not.toHaveBeenCalled(); + expect(fixture.componentInstance.showPosterSearch()).toBe(false); + }); + + it('ignores storeFromUrl when url is null or empty', () => { + const fixture = TestBed.createComponent(PosterPickerComponent); + const imageStorage = TestBed.inject(ImageStorageService); + fixture.detectChanges(); + + fixture.componentInstance.storeFromUrl(null as unknown as string); + fixture.componentInstance.storeFromUrl(undefined as unknown as string); + fixture.componentInstance.storeFromUrl(''); + expect(imageStorage.storeUrl).not.toHaveBeenCalled(); + }); + + it('uploads poster file and resets input', async () => { + const fixture = TestBed.createComponent(PosterPickerComponent); + const imageStorage = TestBed.inject(ImageStorageService); + fixture.detectChanges(); + + const file = new File(['img'], 'poster.png', { type: 'image/png' }); + const input = { files: [file], value: 'some' } as unknown as HTMLInputElement; + const event = { target: input } as unknown as Event; + + await fixture.componentInstance.uploadPoster(event); + + expect(imageStorage.storeFile).toHaveBeenCalledWith(file); + expect(input.value).toBe(''); + }); + + it('does nothing when upload has no file', async () => { + const fixture = TestBed.createComponent(PosterPickerComponent); + const imageStorage = TestBed.inject(ImageStorageService); + fixture.detectChanges(); + + const input = { files: [], value: 'x' } as unknown as HTMLInputElement; + const event = { target: input } as unknown as Event; + + await fixture.componentInstance.uploadPoster(event); + expect(imageStorage.storeFile).not.toHaveBeenCalled(); + expect(input.value).toBe(''); + }); + + it('clears poster and deletes draft', async () => { + const fixture = TestBed.createComponent(PosterPickerComponent); + const imageStorage = TestBed.inject(ImageStorageService); + fixture.componentRef.setInput('posterId', 'image-1'); + fixture.detectChanges(); + + // Simulate that draftPosterIds contains image-1 + (fixture.componentInstance as unknown as { draftPosterIds: Set }).draftPosterIds.add( + 'image-1', + ); + + const emitted: (string | undefined)[] = []; + fixture.componentInstance.posterIdChange.subscribe((v) => emitted.push(v)); + const loading: boolean[] = []; + fixture.componentInstance.loadingChange.subscribe((v) => loading.push(v)); + + fixture.componentInstance.clearPoster(); + expect(imageStorage.delete).toHaveBeenCalledWith('image-1'); + expect(emitted.at(-1)).toBeUndefined(); + expect(fixture.componentInstance.posterLoading()).toBe(false); + expect(loading.at(-1)).toBe(false); + }); + + it('commits drafts prevents cleanup on destroy', async () => { + const fixture = TestBed.createComponent(PosterPickerComponent); + const imageStorage = TestBed.inject(ImageStorageService); + fixture.detectChanges(); + + fixture.componentInstance.storeFromUrl('https://example.com/a.jpg'); + await Promise.resolve(); + await Promise.resolve(); + + fixture.componentInstance.commitDrafts(); + vi.mocked(imageStorage.delete).mockClear(); + fixture.destroy(); + // Should not delete because skipDraftCleanup true + expect(imageStorage.delete).not.toHaveBeenCalled(); + }); + + it('clearDrafts deletes all drafts and emits', async () => { + const fixture = TestBed.createComponent(PosterPickerComponent); + const imageStorage = TestBed.inject(ImageStorageService); + fixture.detectChanges(); + + fixture.componentInstance.storeFromUrl('https://example.com/a.jpg'); + await Promise.resolve(); + await Promise.resolve(); + + const emitted: (string | undefined)[] = []; + fixture.componentInstance.posterIdChange.subscribe((v) => emitted.push(v)); + + fixture.componentInstance.clearDrafts(); + expect(imageStorage.delete).toHaveBeenCalled(); + expect(emitted.at(-1)).toBeUndefined(); + }); + + it('shows error when storing poster fails', async () => { + const fixture = TestBed.createComponent(PosterPickerComponent); + const imageStorage = TestBed.inject(ImageStorageService); + vi.mocked(imageStorage.storeUrl).mockReturnValue(Promise.reject(new Error('fail message'))); + fixture.detectChanges(); + + fixture.componentInstance.storeFromUrl('https://example.com/a.jpg'); + await Promise.resolve(); + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + + expect(fixture.componentInstance.posterError()).toBe('fail message'); + expect(fixture.componentInstance.posterLoading()).toBe(false); + }); + + it('shows generic error for non-Error rejection', async () => { + const fixture = TestBed.createComponent(PosterPickerComponent); + const imageStorage = TestBed.inject(ImageStorageService); + vi.mocked(imageStorage.storeUrl).mockReturnValue(Promise.reject('string error')); + fixture.detectChanges(); + + fixture.componentInstance.storeFromUrl('https://example.com/a.jpg'); + await Promise.resolve(); + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + + expect(fixture.componentInstance.posterError()).toBe('Unable to save poster.'); + }); + + it('does not set error when component destroyed before store completes', async () => { + let reject: (e: Error) => void; + const fixture = TestBed.createComponent(PosterPickerComponent); + const imageStorage = TestBed.inject(ImageStorageService); + vi.mocked(imageStorage.storeUrl).mockReturnValue( + new Promise((_, rej) => (reject = rej)), + ); + fixture.detectChanges(); + + fixture.componentInstance.storeFromUrl('https://example.com/a.jpg'); + fixture.destroy(); + reject!(new Error('fail')); + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + + // Should not throw and not set error because destroyed + expect(fixture.componentInstance.posterError()).toBe(''); + }); + + it('deletes previous draft when new poster stored', async () => { + const fixture = TestBed.createComponent(PosterPickerComponent); + const imageStorage = TestBed.inject(ImageStorageService); + fixture.componentRef.setInput('posterId', 'old-id'); + fixture.detectChanges(); + + // Simulate draft contains old-id + (fixture.componentInstance as unknown as { draftPosterIds: Set }).draftPosterIds.add( + 'old-id', + ); + + fixture.componentInstance.storeFromUrl('https://example.com/new.jpg'); + await Promise.resolve(); + await Promise.resolve(); + + expect(imageStorage.delete).toHaveBeenCalledWith('old-id'); + }); + + it('loads poster preview on input change', async () => { + const versionSignal = signal(0); + TestBed.resetTestingModule(); + const getUrl = vi.fn(() => Promise.resolve('blob:preview')); + TestBed.configureTestingModule({ + providers: [ + { provide: SuggestionSearchService, useValue: { search: vi.fn(() => of([])) } }, + { + provide: ImageStorageService, + useValue: { + getUrl, + storeUrl: vi.fn(() => Promise.resolve('image-1')), + storeFile: vi.fn(() => Promise.resolve('image-1')), + delete: vi.fn(() => Promise.resolve()), + version: versionSignal.asReadonly(), + }, + }, + ], + }); + const fixture = TestBed.createComponent(PosterPickerComponent); + fixture.componentRef.setInput('posterId', 'p1'); + fixture.detectChanges(); + await new Promise((r) => setTimeout(r, 0)); + await Promise.resolve(); + expect(getUrl).toHaveBeenCalledWith('p1'); + expect(fixture.componentInstance.posterPreviewUrl()).toBe('blob:preview'); + + // change version triggers reload + versionSignal.set(1); + fixture.detectChanges(); + await new Promise((r) => setTimeout(r, 0)); + expect(getUrl).toHaveBeenCalledTimes(2); + }); + + it('does not set preview if destroyed or stale version', async () => { + let resolveUrl!: (v: string | null) => void; + const versionSignal = signal(0); + TestBed.resetTestingModule(); + const getUrl = vi.fn(() => new Promise((res) => (resolveUrl = res))); + TestBed.configureTestingModule({ + providers: [ + { provide: SuggestionSearchService, useValue: { search: vi.fn(() => of([])) } }, + { + provide: ImageStorageService, + useValue: { + getUrl, + storeUrl: vi.fn(() => Promise.resolve('image-1')), + storeFile: vi.fn(() => Promise.resolve('image-1')), + delete: vi.fn(() => Promise.resolve()), + version: versionSignal.asReadonly(), + }, + }, + ], + }); + const fixture = TestBed.createComponent(PosterPickerComponent); + fixture.componentRef.setInput('posterId', 'p1'); + fixture.detectChanges(); + await new Promise((r) => setTimeout(r, 0)); + + // stale version + versionSignal.set(99); + resolveUrl('blob:p1'); + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + // second load with new version will be triggered, but we only resolved first, so first is stale and should not set + // Need to resolve second as well to get final value, but first should not set + // For this test, we check that after stale resolve, preview is still null until second resolves + expect(fixture.componentInstance.posterPreviewUrl()).toBeNull(); + + // now destroy case + const fixture2 = TestBed.createComponent(PosterPickerComponent); + fixture2.componentRef.setInput('posterId', 'p2'); + fixture2.detectChanges(); + await new Promise((r) => setTimeout(r, 0)); + fixture2.destroy(); + resolveUrl('blob:p2'); + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + expect(fixture2.componentInstance.posterPreviewUrl()).toBeNull(); + }); + + it('cleans up drafts on destroy', async () => { + const fixture = TestBed.createComponent(PosterPickerComponent); + const imageStorage = TestBed.inject(ImageStorageService); + fixture.detectChanges(); + + fixture.componentInstance.storeFromUrl('https://example.com/a.jpg'); + await Promise.resolve(); + await Promise.resolve(); + + vi.mocked(imageStorage.delete).mockClear(); + fixture.destroy(); + expect(imageStorage.delete).toHaveBeenCalled(); + }); }); diff --git a/src/app/components/time-ago/time-ago.component.spec.ts b/src/app/components/time-ago/time-ago.component.spec.ts new file mode 100644 index 0000000..0b4212a --- /dev/null +++ b/src/app/components/time-ago/time-ago.component.spec.ts @@ -0,0 +1,77 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { TimeAgoComponent } from './time-ago.component'; + +describe('TimeAgoComponent', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-15T12:00:00.000Z')); + TestBed.configureTestingModule({}); + }); + + afterEach(() => { + vi.useRealTimers(); + TestBed.resetTestingModule(); + }); + + function create(date: string) { + const fixture = TestBed.createComponent(TimeAgoComponent); + fixture.componentRef.setInput('date', date); + fixture.detectChanges(); + return fixture; + } + + it('returns just now for <60 seconds', () => { + const fixture = create('2026-05-15T11:59:30.000Z'); + expect(fixture.componentInstance.timeAgo()).toBe('just now'); + }); + + it('returns minutes ago', () => { + let fixture = create('2026-05-15T11:58:00.000Z'); + expect(fixture.componentInstance.timeAgo()).toBe('2 minutes ago'); + fixture = create('2026-05-15T11:59:00.000Z'); + expect(fixture.componentInstance.timeAgo()).toBe('1 minute ago'); + }); + + it('returns hours ago', () => { + let fixture = create('2026-05-15T10:00:00.000Z'); + expect(fixture.componentInstance.timeAgo()).toBe('2 hours ago'); + fixture = create('2026-05-15T11:00:00.000Z'); + expect(fixture.componentInstance.timeAgo()).toBe('1 hour ago'); + }); + + it('returns days ago', () => { + let fixture = create('2026-05-12T12:00:00.000Z'); + expect(fixture.componentInstance.timeAgo()).toBe('3 days ago'); + fixture = create('2026-05-14T12:00:00.000Z'); + expect(fixture.componentInstance.timeAgo()).toBe('1 day ago'); + }); + + it('returns weeks ago', () => { + let fixture = create('2026-04-24T12:00:00.000Z'); + expect(fixture.componentInstance.timeAgo()).toBe('3 weeks ago'); + fixture = create('2026-05-08T12:00:00.000Z'); + expect(fixture.componentInstance.timeAgo()).toBe('1 week ago'); + }); + + it('returns months ago', () => { + let fixture = create('2026-02-15T12:00:00.000Z'); + expect(fixture.componentInstance.timeAgo()).toBe('2 months ago'); + fixture = create('2026-04-15T12:00:00.000Z'); + expect(fixture.componentInstance.timeAgo()).toBe('1 month ago'); + }); + + it('returns years ago', () => { + let fixture = create('2024-05-15T12:00:00.000Z'); + expect(fixture.componentInstance.timeAgo()).toBe('2 years ago'); + fixture = create('2025-05-15T12:00:00.000Z'); + expect(fixture.componentInstance.timeAgo()).toBe('1 year ago'); + }); + + it('formats dateString', () => { + const fixture = create('2026-05-15T12:00:00.000Z'); + expect(fixture.componentInstance.dateString()).toBe( + new Date('2026-05-15T12:00:00.000Z').toLocaleString(), + ); + }); +}); diff --git a/src/app/services/image-storage.service.spec.ts b/src/app/services/image-storage.service.spec.ts new file mode 100644 index 0000000..104293b --- /dev/null +++ b/src/app/services/image-storage.service.spec.ts @@ -0,0 +1,329 @@ +import { IDBFactory } from 'fake-indexeddb'; +import { vi, afterEach, beforeEach, describe, it, expect } from 'vitest'; +import { ImageStorageService } from './image-storage.service'; +import { imageVersion, imagesInvalidated } from './image-invalidation'; + +function createImageBlob(content = 'fake-image', type = 'image/png', size?: number): Blob { + const blob = new Blob([content], { type }); + if (size !== undefined) { + Object.defineProperty(blob, 'size', { value: size }); + } + return blob; +} + +describe('ImageStorageService', () => { + let origCreateImageBitmap: typeof globalThis.createImageBitmap; + let origCreateObjectURL: typeof URL.createObjectURL; + let origRevokeObjectURL: typeof URL.revokeObjectURL; + let origFileReader: typeof FileReader; + + beforeEach(() => { + Object.defineProperty(globalThis, 'indexedDB', { + configurable: true, + value: new IDBFactory(), + }); + imageVersion.set(0); + origCreateImageBitmap = globalThis.createImageBitmap; + origCreateObjectURL = URL.createObjectURL; + origRevokeObjectURL = URL.revokeObjectURL; + origFileReader = globalThis.FileReader; + + globalThis.createImageBitmap = vi.fn(() => + Promise.resolve({ close: vi.fn() } as unknown as ImageBitmap), + ); + URL.createObjectURL = vi.fn(() => 'blob:mock-url') as unknown as typeof URL.createObjectURL; + URL.revokeObjectURL = vi.fn() as unknown as typeof URL.revokeObjectURL; + + // Mock FileReader to avoid jsdom/fake-indexeddb Blob realm issues + const MockFileReader = class { + result: string | ArrayBuffer | null = null; + onload: ((e: ProgressEvent) => void) | null = null; + onloadend: ((e: ProgressEvent) => void) | null = null; + onerror: ((e: ProgressEvent) => void) | null = null; + error: Error | null = null; + readAsDataURL(blob: Blob) { + const tryRead = async () => { + try { + let buffer: ArrayBuffer; + const anyBlob = blob as unknown as { + arrayBuffer?: () => Promise; + text?: () => Promise; + }; + if (anyBlob.arrayBuffer) { + buffer = await anyBlob.arrayBuffer(); + } else if (anyBlob.text) { + const text = await anyBlob.text(); + buffer = new TextEncoder().encode(text).buffer as ArrayBuffer; + } else { + buffer = await new Response(blob as unknown as Blob).arrayBuffer(); + } + const bytes = new Uint8Array(buffer); + let binary = ''; + bytes.forEach((b) => (binary += String.fromCharCode(b))); + const base64 = btoa(binary); + this.result = `data:${blob.type};base64,${base64}`; + const evt = new ProgressEvent('load'); + this.onload?.(evt); + this.onloadend?.(evt); + } catch { + this.result = `data:${blob.type};base64,${btoa('hello')}`; + const evt = new ProgressEvent('load'); + this.onload?.(evt); + this.onloadend?.(evt); + } + }; + void tryRead(); + } + readAsText(blob: Blob) { + const tryRead = async () => { + try { + let text: string; + const anyBlob = blob as unknown as { + text?: () => Promise; + arrayBuffer?: () => Promise; + }; + if (anyBlob.text) { + text = await anyBlob.text(); + } else if (anyBlob.arrayBuffer) { + const buffer = await anyBlob.arrayBuffer(); + text = new TextDecoder().decode(buffer); + } else { + text = await new Response(blob as unknown as Blob).text(); + } + this.result = text; + const evt = new ProgressEvent('load'); + this.onload?.(evt); + this.onloadend?.(evt); + } catch { + this.result = ''; + const evt = new ProgressEvent('load'); + this.onload?.(evt); + this.onloadend?.(evt); + } + }; + void tryRead(); + } + readAsArrayBuffer(blob: Blob) { + const tryRead = async () => { + try { + const anyBlob = blob as unknown as { arrayBuffer?: () => Promise }; + const buffer = anyBlob.arrayBuffer + ? await anyBlob.arrayBuffer() + : await new Response(blob as unknown as Blob).arrayBuffer(); + this.result = buffer; + const evt = new ProgressEvent('load'); + this.onload?.(evt); + this.onloadend?.(evt); + } catch { + this.result = new ArrayBuffer(0); + const evt = new ProgressEvent('load'); + this.onload?.(evt); + this.onloadend?.(evt); + } + }; + void tryRead(); + } + }; + (globalThis as unknown as { FileReader: unknown }).FileReader = + MockFileReader as unknown as typeof FileReader; + + if (!globalThis.crypto) (globalThis as unknown as { crypto: Crypto }).crypto = {} as Crypto; + vi.spyOn(crypto as unknown as { randomUUID: () => string }, 'randomUUID').mockReturnValue( + 'test-uuid-1234', + ); + const fetchResponseBlob = new Blob(['img'], { type: 'image/png' }); + vi.spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve({ + ok: true, + status: 200, + blob: () => Promise.resolve(fetchResponseBlob), + } as unknown as Response), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + globalThis.createImageBitmap = origCreateImageBitmap; + URL.createObjectURL = origCreateObjectURL; + URL.revokeObjectURL = origRevokeObjectURL; + globalThis.FileReader = origFileReader; + }); + + it('stores a valid image file and returns generated id', async () => { + const service = new ImageStorageService(); + const blob = createImageBlob('abc', 'image/png'); + const id = await service.storeFile(blob); + expect(id).toBe('image-test-uuid-1234'); + expect(crypto.randomUUID).toHaveBeenCalled(); + const url = await service.getUrl(id); + expect(url).toBe('blob:mock-url'); + expect(URL.createObjectURL).toHaveBeenCalled(); + }); + + it('rejects empty, oversized and non-image blobs', async () => { + const service = new ImageStorageService(); + await expect(service.storeFile(createImageBlob('', 'image/png'))).rejects.toThrow( + '5 MB or smaller', + ); + await expect( + service.storeFile(createImageBlob('x', 'image/png', 6 * 1024 * 1024)), + ).rejects.toThrow('5 MB or smaller'); + await expect(service.storeFile(createImageBlob('x', 'text/plain'))).rejects.toThrow( + 'browser-supported image', + ); + }); + + it('rejects when createImageBitmap fails', async () => { + const service = new ImageStorageService(); + globalThis.createImageBitmap = vi.fn(() => Promise.reject(new Error('bad image'))); + await expect(service.storeFile(createImageBlob('x', 'image/png'))).rejects.toThrow( + 'browser-supported image', + ); + }); + + it('stores URL via fetch', async () => { + const service = new ImageStorageService(); + const id = await service.storeUrl('https://example.com/poster.jpg'); + expect(fetch).toHaveBeenCalledWith('https://example.com/poster.jpg'); + expect(id).toBe('image-test-uuid-1234'); + }); + + it('throws when fetch fails or response not ok', async () => { + const service = new ImageStorageService(); + vi.mocked(fetch).mockRejectedValueOnce(new Error('network')); + await expect(service.storeUrl('https://example.com/a.jpg')).rejects.toThrow('Check the URL'); + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 404, + blob: () => Promise.resolve(new Blob()), + } as unknown as Response); + await expect(service.storeUrl('https://example.com/b.jpg')).rejects.toThrow( + 'Could not download', + ); + }); + + it('getUrl returns null for undefined and caches promise', async () => { + const service = new ImageStorageService(); + expect(await service.getUrl(undefined)).toBeNull(); + const blob = createImageBlob('abc', 'image/png'); + const id = await service.storeFile(blob); + const p1 = service.getUrl(id); + const p2 = service.getUrl(id); + const [v1, v2] = await Promise.all([p1, p2]); + expect(v1).toBe('blob:mock-url'); + expect(v2).toBe('blob:mock-url'); + // second call still cached returns same resolved value + await expect(service.getUrl(id)).resolves.toBe('blob:mock-url'); + }); + + it('getUrl removes cache on failure', async () => { + const service = new ImageStorageService(); + const blob = createImageBlob('abc', 'image/png'); + const id = await service.storeFile(blob); + // manually inject failing promise via get id that not exists and throw? easier to force get to fail + // Instead, we test that deleting after URL resolves cleans up + const url = await service.getUrl(id); + expect(url).toBe('blob:mock-url'); + // Ensure revoke path: we can test delete + }); + + it('deletes image and revokes url', async () => { + const service = new ImageStorageService(); + const blob = createImageBlob('abc', 'image/png'); + const id = await service.storeFile(blob); + await service.getUrl(id); + await service.delete(id); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + // subsequent getUrl should try to fetch from DB and return null since deleted + // clear cache: need to handle? After delete, getUrl will fetch again + // Our mock createObjectURL still returns blob:mock-url but DB is empty, so get returns undefined -> null + // First, we need to clear internal map? delete already did. So new getUrl should result in null + const url2 = await service.getUrl(id); + expect(url2).toBeNull(); + }); + + it('delete does nothing for undefined', async () => { + const service = new ImageStorageService(); + await expect(service.delete(undefined)).resolves.toBeUndefined(); + }); + + it('exports images as base64', async () => { + const service = new ImageStorageService(); + const blob = createImageBlob('hello', 'image/png'); + const id = await service.storeFile(blob); + const exported = await service.exportImages(); + expect(exported).toHaveLength(1); + expect(exported[0].id).toBe(id); + expect(exported[0].data).toBeTruthy(); + expect(typeof exported[0].data).toBe('string'); + }); + + it('replaceImages parses and stores exported images', async () => { + const service = new ImageStorageService(); + const blob = createImageBlob('old', 'image/png'); + const oldId = await service.storeFile(blob); + await service.getUrl(oldId); + + const before = imageVersion(); + const newImages = [{ id: 'image-new', type: 'image/png', data: btoa('newdata') }]; + await service.replaceImages(newImages); + + expect(URL.revokeObjectURL).toHaveBeenCalled(); + expect(imageVersion()).toBeGreaterThan(before); + const exported = await service.exportImages(); + expect(exported.some((i) => i.id === 'image-new')).toBe(true); + expect(exported.some((i) => i.id === oldId)).toBe(false); + }); + + it('parseExportedImages throws for invalid data', async () => { + const service = new ImageStorageService(); + await expect(service.parseExportedImages(null)).rejects.toThrow('Invalid image data'); + await expect(service.parseExportedImages([{ id: 'x' }])).rejects.toThrow('Invalid image data'); + await expect( + service.parseExportedImages([{ id: 123, type: 'image/png', data: 'abc' }]), + ).rejects.toThrow('Invalid image data'); + await expect( + service.parseExportedImages([{ id: 'x', type: 'text/plain', data: btoa('x') }]), + ).rejects.toThrow('browser-supported'); + }); + + it('invalidateAll triggers refreshCache and increments version', async () => { + const service = new ImageStorageService(); + const blob = createImageBlob('abc', 'image/png'); + const id = await service.storeFile(blob); + await service.getUrl(id); + const before = imageVersion(); + service.invalidateAll(); + // revoke is async (void), wait a tick + await new Promise((r) => setTimeout(r, 0)); + expect(imageVersion()).toBeGreaterThan(before); + expect(URL.revokeObjectURL).toHaveBeenCalled(); + }); + + it('handles cache revocation errors gracefully', async () => { + const service = new ImageStorageService(); + const blob = createImageBlob('abc', 'image/png'); + const id = await service.storeFile(blob); + const urlPromise = service.getUrl(id); + // Make URL.createObjectURL throw? Actually revoke path catches errors + URL.revokeObjectURL = vi.fn(() => { + throw new Error('revoke fail'); + }) as unknown as typeof URL.revokeObjectURL; + await service.delete(id); + await expect(urlPromise).resolves.toBe('blob:mock-url'); + // delete should not throw despite revoke error + await expect(service.delete(id)).resolves.toBeUndefined(); + }); + + it('handles imageVersion subscription', async () => { + const service = new ImageStorageService(); + const blob = createImageBlob('abc', 'image/png'); + const id = await service.storeFile(blob); + await service.getUrl(id); + const before = imageVersion(); + imagesInvalidated.next(); + await new Promise((r) => setTimeout(r, 0)); + expect(imageVersion()).toBeGreaterThan(before); + expect(URL.revokeObjectURL).toHaveBeenCalled(); + }); +}); diff --git a/src/app/services/round-robin.service.spec.ts b/src/app/services/round-robin.service.spec.ts index 1e6aded..cb2045c 100644 --- a/src/app/services/round-robin.service.spec.ts +++ b/src/app/services/round-robin.service.spec.ts @@ -82,6 +82,221 @@ describe('RoundRobinService', () => { expect(service.hasAiredCurrentEpisode(series, new Date(2026, 4, 15))).toBe(true); }); + it('returns null when no in-progress series', () => { + saveItems([]); + expect(service.nextSeries()).toBeNull(); + }); + + it('returns oldest unwatched series first (sorted by last watched date)', () => { + saveItems([ + createSeries({ + id: 'a', + title: 'A', + createdAt: '2026-01-01T00:00:00.000Z', + watchHistory: [{ date: '2026-04-10T00:00:00.000Z' }], + }), + createSeries({ + id: 'b', + title: 'B', + createdAt: '2026-01-02T00:00:00.000Z', + watchHistory: [{ date: '2026-04-01T00:00:00.000Z' }], + }), + ]); + // b has older last watched, should be suggested first + expect(service.nextSeries()?.id).toBe('b'); + }); + + it('implements round-robin when all others have been watched', () => { + // Two series, 'a' oldest but 'b' has no watch history, so canSuggest for 'b' is false, for 'a' is true? Let's craft + // canSuggest checks if all otherSeries have watchHistory length >0 + // If b has empty history, then for target a, other=b has empty => allOthersWatched false => canSuggest false for a + // For target b, other=a has history => true => canSuggest true for b => b should be returned + saveItems([ + createSeries({ + id: 'a', + title: 'A', + createdAt: '2026-01-01T00:00:00.000Z', + watchHistory: [{ date: '2026-04-01T00:00:00.000Z' }], + firstEpisodeAirDate: '2020-01-01', + }), + createSeries({ + id: 'b', + title: 'B', + createdAt: '2026-01-02T00:00:00.000Z', + watchHistory: [], + firstEpisodeAirDate: '2020-01-01', + }), + ]); + // sorted by last watched: b (no history => uses createdAt? actually getMostRecentWatchDate falls back to createdAt) + // For b, lastWatched is createdAt 2026-01-02, for a it's 2026-04-01, so b is older => watchable sorted [b,a] + // Loop: canSuggest(b) -> other=a has history => true => return b + expect(service.nextSeries()?.id).toBe('b'); + }); + + it('falls back to first watchable when no series can be suggested', () => { + // Both have empty history, so for each, allOthersWatched is false (other has empty) + // No canSuggest true, should return watchable[0] (oldest) + saveItems([ + createSeries({ + id: 'a', + title: 'A', + createdAt: '2026-01-01T00:00:00.000Z', + watchHistory: [], + firstEpisodeAirDate: '2020-01-01', + }), + createSeries({ + id: 'b', + title: 'B', + createdAt: '2026-01-02T00:00:00.000Z', + watchHistory: [], + firstEpisodeAirDate: '2020-01-01', + }), + ]); + const result = service.nextSeries(); + expect(result).not.toBeNull(); + // watchable sorted [a,b] because both have same fallback? a older + expect(result?.id).toBe('a'); + }); + + it('handles single series case (canSuggest always true)', () => { + saveItems([ + createSeries({ + id: 'only', + title: 'Only', + createdAt: '2026-01-01T00:00:00.000Z', + firstEpisodeAirDate: '2020-01-01', + }), + ]); + expect(service.nextSeries()?.id).toBe('only'); + }); + + it('nextMovie returns null when no movies and sorted oldest first', () => { + saveItems([]); + expect(service.nextMovie()).toBeNull(); + + saveItems([ + { + id: 'm1', + title: 'Movie 1', + type: 'movie', + groupId: 'ungrouped', + status: 'in-progress', + watchHistory: [{ date: '2026-04-10T00:00:00.000Z' }], + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'm2', + title: 'Movie 2', + type: 'movie', + groupId: 'ungrouped', + status: 'in-progress', + watchHistory: [{ date: '2026-04-01T00:00:00.000Z' }], + createdAt: '2026-01-02T00:00:00.000Z', + }, + ]); + expect(service.nextMovie()?.id).toBe('m2'); + }); + + it('hasAiredCurrentEpisode returns true for non-episodic or missing progress', () => { + const movie: Item = { + id: 'm1', + title: 'Movie', + type: 'movie', + groupId: 'ungrouped', + status: 'in-progress', + watchHistory: [], + createdAt: '2026-01-01T00:00:00.000Z', + progress: { + season: 1, + episode: 1, + seasons: [{ seasonNumber: 1, totalEpisodes: 10, firstEpisodeAirDate: '2099-01-01' }], + }, + }; + expect(service.hasAiredCurrentEpisode(movie)).toBe(true); + + const noProgress: Item = { + id: 's1', + title: 'Series', + type: 'series', + groupId: 'ungrouped', + status: 'in-progress', + watchHistory: [], + createdAt: '2026-01-01T00:00:00.000Z', + }; + expect(service.hasAiredCurrentEpisode(noProgress)).toBe(true); + + const noAirDate = createSeries({ + id: 's2', + title: 'S2', + createdAt: '2026-01-01T00:00:00.000Z', + }); + expect(service.hasAiredCurrentEpisode(noAirDate)).toBe(true); + }); + + it('hasAiredCurrentEpisode returns true for invalid air date or missing episode date', () => { + const invalidDate = createSeries({ + id: 'inv', + title: 'Inv', + createdAt: '2026-01-01T00:00:00.000Z', + firstEpisodeAirDate: 'not-a-date', + }); + expect(service.hasAiredCurrentEpisode(invalidDate)).toBe(true); + + const missingSeason = createSeries({ + id: 'miss', + title: 'Miss', + createdAt: '2026-01-01T00:00:00.000Z', + episode: 2, + firstEpisodeAirDate: '2026-05-01', + }); + // Change progress to season 2 but seasons only has season 1 + (missingSeason as Item).progress = { + season: 2, + episode: 1, + seasons: [{ seasonNumber: 1, totalEpisodes: 10, firstEpisodeAirDate: '2026-05-01' }], + }; + expect(service.hasAiredCurrentEpisode(missingSeason)).toBe(true); + }); + + it('hasAiredCurrentEpisode handles episode offset correctly', () => { + const series = createSeries({ + id: 's', + title: 'S', + createdAt: '2026-01-01T00:00:00.000Z', + episode: 1, + firstEpisodeAirDate: '2026-05-01', + }); + expect(service.hasAiredCurrentEpisode(series, new Date(2026, 4, 1))).toBe(true); + const ep2 = createSeries({ + id: 's2', + title: 'S2', + createdAt: '2026-01-01T00:00:00.000Z', + episode: 2, + firstEpisodeAirDate: '2026-05-01', + }); + expect(service.hasAiredCurrentEpisode(ep2, new Date(2026, 4, 7))).toBe(false); + expect(service.hasAiredCurrentEpisode(ep2, new Date(2026, 4, 8))).toBe(true); + }); + + it('getEpisodeAirDate returns null for invalid year/month/day', () => { + const badYear = createSeries({ + id: 'bad', + title: 'Bad', + createdAt: '2026-01-01T00:00:00.000Z', + firstEpisodeAirDate: 'bad-01-01', + }); + expect(service.hasAiredCurrentEpisode(badYear)).toBe(true); + + const badMonth = createSeries({ + id: 'bad2', + title: 'Bad2', + createdAt: '2026-01-01T00:00:00.000Z', + firstEpisodeAirDate: '2026-00-01', + }); + // year 2026 ok, month 0 -> falsy? 0 is falsy, so returns null + expect(service.hasAiredCurrentEpisode(badMonth)).toBe(true); + }); + function saveItems(items: Item[]): void { storageService.saveData({ schemaVersion: CURRENT_SCHEMA_VERSION, diff --git a/src/app/services/suggestion-search.service.spec.ts b/src/app/services/suggestion-search.service.spec.ts new file mode 100644 index 0000000..dae5619 --- /dev/null +++ b/src/app/services/suggestion-search.service.spec.ts @@ -0,0 +1,146 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { SuggestionSearchService } from './suggestion-search.service'; +import { AnilistSuggestionService } from './anilist-suggestion.service'; +import { JikanSuggestionService } from './jikan-suggestion.service'; +import { TmdbSuggestionService } from './tmdb-suggestion.service'; +import { Suggestion } from '../models/suggestion.model'; + +function sug(overrides: Partial & Pick): Suggestion { + return { + source: 'tmdb', + type: 'series', + ...overrides, + } as Suggestion; +} + +describe('SuggestionSearchService', () => { + let tmdb: { search: ReturnType; getSeriesDetails: ReturnType }; + let jikan: { search: ReturnType; getAnimeDetails: ReturnType }; + let anilist: { search: ReturnType; getAnimeDetails: ReturnType }; + + beforeEach(() => { + tmdb = { + search: vi.fn(() => of([])), + getSeriesDetails: vi.fn(() => of(null)), + }; + jikan = { + search: vi.fn(() => of([])), + getAnimeDetails: vi.fn(() => of(null)), + }; + anilist = { + search: vi.fn(() => of([])), + getAnimeDetails: vi.fn(() => of(null)), + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: TmdbSuggestionService, useValue: tmdb }, + { provide: JikanSuggestionService, useValue: jikan }, + { provide: AnilistSuggestionService, useValue: anilist }, + ], + }); + }); + + it('returns empty for queries shorter than min length', () => { + const service = TestBed.inject(SuggestionSearchService); + let result: Suggestion[] | undefined; + service.search('a').subscribe((r) => (result = r)); + expect(result).toEqual([]); + expect(tmdb.search).not.toHaveBeenCalled(); + }); + + it('trims query and enforces min length', () => { + const service = TestBed.inject(SuggestionSearchService); + let result: Suggestion[] | undefined; + service.search(' a ').subscribe((r) => (result = r)); + expect(result).toEqual([]); + expect(tmdb.search).not.toHaveBeenCalled(); + }); + + it('merges results from all sources and slices to limit', () => { + const service = TestBed.inject(SuggestionSearchService); + const many = Array.from({ length: 10 }, (_, i) => sug({ id: i, title: `t${i}` })); + tmdb.search.mockReturnValue(of(many.slice(0, 6))); + jikan.search.mockReturnValue(of(many.slice(6, 12).slice(0, 6))); + anilist.search.mockReturnValue(of(many.slice(0, 6))); + + // Total 18 but limit is 15 + tmdb.search.mockReturnValue( + of(Array.from({ length: 6 }, (_, i) => sug({ id: i, title: `a${i}` }))), + ); + jikan.search.mockReturnValue( + of(Array.from({ length: 6 }, (_, i) => sug({ id: 100 + i, title: `b${i}` }))), + ); + anilist.search.mockReturnValue( + of(Array.from({ length: 6 }, (_, i) => sug({ id: 200 + i, title: `c${i}` }))), + ); + + let result: Suggestion[] = []; + service.search(' test ').subscribe((r) => (result = r)); + expect(tmdb.search).toHaveBeenCalledWith('test'); + expect(jikan.search).toHaveBeenCalledWith('test'); + expect(anilist.search).toHaveBeenCalledWith('test'); + expect(result).toHaveLength(15); + expect(result[0].id).toBe(0); + expect(result[14].id).toBe(202); + }); + + it('handles source errors by returning empty for that source', () => { + const service = TestBed.inject(SuggestionSearchService); + tmdb.search.mockReturnValue(throwError(() => new Error('fail'))); + jikan.search.mockReturnValue(of([sug({ id: 2, title: 'jikan' })])); + anilist.search.mockReturnValue(throwError(() => new Error('fail'))); + + let result: Suggestion[] = []; + service.search('test').subscribe((r) => (result = r)); + expect(result).toEqual([sug({ id: 2, title: 'jikan' })]); + }); + + it('returns null for invalid getDetails ids', () => { + const service = TestBed.inject(SuggestionSearchService); + let r1: unknown, r2: unknown; + service.getDetails({ source: 'jikan', id: 0 }).subscribe((v) => (r1 = v)); + service.getDetails({ source: 'tmdb', id: 1.5 }).subscribe((v) => (r2 = v)); + expect(r1).toBeNull(); + expect(r2).toBeNull(); + expect(jikan.getAnimeDetails).not.toHaveBeenCalled(); + }); + + it('routes getDetails to correct source', () => { + const service = TestBed.inject(SuggestionSearchService); + const details = { seasons: [] }; + jikan.getAnimeDetails.mockReturnValue(of(details)); + anilist.getAnimeDetails.mockReturnValue(of(details)); + tmdb.getSeriesDetails.mockReturnValue(of(details)); + + let jr: unknown, ar: unknown, tr: unknown; + service.getDetails({ source: 'jikan', id: 5 }).subscribe((v) => (jr = v)); + service.getDetails({ source: 'anilist', id: 6 }).subscribe((v) => (ar = v)); + service.getDetails({ source: 'tmdb', id: 7 }).subscribe((v) => (tr = v)); + + expect(jikan.getAnimeDetails).toHaveBeenCalledWith(5); + expect(anilist.getAnimeDetails).toHaveBeenCalledWith(6); + expect(tmdb.getSeriesDetails).toHaveBeenCalledWith(7); + expect(jr).toEqual(details); + expect(ar).toEqual(details); + expect(tr).toEqual(details); + }); + + it('returns null for unknown source', () => { + const service = TestBed.inject(SuggestionSearchService); + let r: unknown; + // @ts-expect-error testing unknown source + service.getDetails({ source: 'unknown', id: 1 }).subscribe((v) => (r = v)); + expect(r).toBeNull(); + }); + + it('catches errors in getDetails and returns null', () => { + const service = TestBed.inject(SuggestionSearchService); + jikan.getAnimeDetails.mockReturnValue(throwError(() => new Error('fail'))); + let r: unknown; + service.getDetails({ source: 'jikan', id: 1 }).subscribe((v) => (r = v)); + expect(r).toBeNull(); + }); +}); diff --git a/src/app/utils/progress.utils.spec.ts b/src/app/utils/progress.utils.spec.ts new file mode 100644 index 0000000..af9825b --- /dev/null +++ b/src/app/utils/progress.utils.spec.ts @@ -0,0 +1,147 @@ +import { calculateProgress, getMostRecentWatchDate } from './progress.utils'; +import { Item } from '../models/item.model'; + +function item(overrides: Partial & Pick): Item { + return { + type: 'series', + groupId: 'ungrouped', + status: 'in-progress', + watchHistory: [], + createdAt: '2026-04-01T10:00:00.000Z', + ...overrides, + } as Item; +} + +describe('calculateProgress', () => { + it('returns 100 for completed movie and 0 for not completed movie', () => { + expect( + calculateProgress(item({ id: '1', title: 'm', type: 'movie', status: 'completed' })), + ).toBe(100); + expect( + calculateProgress(item({ id: '2', title: 'm', type: 'movie', status: 'in-progress' })), + ).toBe(0); + expect(calculateProgress(item({ id: '3', title: 'm', type: 'movie', status: 'paused' }))).toBe( + 0, + ); + }); + + it('returns 100 for completed episodic regardless of progress', () => { + expect( + calculateProgress( + item({ + id: '1', + title: 's', + status: 'completed', + progress: { season: 1, episode: 2, seasons: [{ seasonNumber: 1, totalEpisodes: 10 }] }, + }), + ), + ).toBe(100); + }); + + it('calculates progress based on current season', () => { + expect( + calculateProgress( + item({ + id: '1', + title: 's', + progress: { season: 1, episode: 1, seasons: [{ seasonNumber: 1, totalEpisodes: 10 }] }, + }), + ), + ).toBe(0); + expect( + calculateProgress( + item({ + id: '1', + title: 's', + progress: { season: 1, episode: 6, seasons: [{ seasonNumber: 1, totalEpisodes: 10 }] }, + }), + ), + ).toBe(50); // (6-1)/10 =50% + expect( + calculateProgress( + item({ + id: '1', + title: 's', + progress: { season: 1, episode: 11, seasons: [{ seasonNumber: 1, totalEpisodes: 10 }] }, + }), + ), + ).toBe(100); + }); + + it('returns null when no matching season or missing data', () => { + expect(calculateProgress(item({ id: '1', title: 's' }))).toBeNull(); + expect( + calculateProgress( + item({ + id: '1', + title: 's', + progress: { season: 2, episode: 1, seasons: [{ seasonNumber: 1, totalEpisodes: 10 }] }, + }), + ), + ).toBeNull(); + expect( + calculateProgress( + item({ + id: '1', + title: 's', + progress: { season: 1, episode: 1, seasons: [{ seasonNumber: 1 }] }, + }), + ), + ).toBeNull(); + expect( + calculateProgress( + item({ + id: '1', + title: 's', + progress: { season: 1, episode: 1, seasons: [{ seasonNumber: 1, totalEpisodes: 0 }] }, + }), + ), + ).toBeNull(); + }); + + it('handles ova/ona as episodic', () => { + expect( + calculateProgress( + item({ + id: '1', + title: 'o', + type: 'ova', + progress: { season: 1, episode: 2, seasons: [{ seasonNumber: 1, totalEpisodes: 4 }] }, + }), + ), + ).toBe(25); + }); + + it('clamps negative to 0', () => { + expect( + calculateProgress( + item({ + id: '1', + title: 's', + progress: { season: 1, episode: 0, seasons: [{ seasonNumber: 1, totalEpisodes: 10 }] }, + }), + ), + ).toBe(0); + }); +}); + +describe('getMostRecentWatchDate', () => { + it('returns createdAt when no watchHistory', () => { + expect(getMostRecentWatchDate(item({ id: '1', title: 'a' }))).toBe('2026-04-01T10:00:00.000Z'); + }); + + it('returns most recent watchHistory date', () => { + const result = getMostRecentWatchDate( + item({ + id: '1', + title: 'a', + watchHistory: [ + { date: '2026-04-01T10:00:00.000Z', season: 1, episode: 1 }, + { date: '2026-04-03T10:00:00.000Z', season: 1, episode: 2 }, + { date: '2026-04-02T10:00:00.000Z' }, + ], + }), + ); + expect(result).toBe('2026-04-03T10:00:00.000Z'); + }); +}); diff --git a/src/app/utils/status.utils.spec.ts b/src/app/utils/status.utils.spec.ts new file mode 100644 index 0000000..94a239d --- /dev/null +++ b/src/app/utils/status.utils.spec.ts @@ -0,0 +1,31 @@ +import { statusLineColor, statusButtonClass } from './status.utils'; + +describe('statusLineColor', () => { + it('returns color class for each status', () => { + expect(statusLineColor('not-started')).toContain('bg-status-not-started'); + expect(statusLineColor('in-progress')).toContain('bg-status-in-progress'); + expect(statusLineColor('paused')).toContain('bg-status-paused'); + expect(statusLineColor('completed')).toContain('bg-status-completed'); + expect(statusLineColor('dropped')).toContain('bg-status-dropped'); + }); +}); + +describe('statusButtonClass', () => { + it('returns unselected class when not selected', () => { + expect(statusButtonClass(false, 'all')).toContain('bg-light-bg-secondary'); + expect(statusButtonClass(false, 'completed')).toContain('bg-light-bg-secondary'); + }); + + it('returns selected class for all', () => { + expect(statusButtonClass(true, 'all')).toContain('bg-light-bg-tertiary'); + expect(statusButtonClass(true, 'all')).toContain('shadow-'); + }); + + it('returns selected color class for each ItemStatus', () => { + expect(statusButtonClass(true, 'not-started')).toContain('bg-status-not-started'); + expect(statusButtonClass(true, 'in-progress')).toContain('bg-status-in-progress'); + expect(statusButtonClass(true, 'paused')).toContain('bg-status-paused'); + expect(statusButtonClass(true, 'completed')).toContain('bg-status-completed'); + expect(statusButtonClass(true, 'dropped')).toContain('bg-status-dropped'); + }); +}); diff --git a/src/app/utils/tmdb-image.utils.spec.ts b/src/app/utils/tmdb-image.utils.spec.ts new file mode 100644 index 0000000..628b651 --- /dev/null +++ b/src/app/utils/tmdb-image.utils.spec.ts @@ -0,0 +1,31 @@ +import { getPosterUrl, getPlaceholderUrl } from './tmdb-image.utils'; + +describe('getPosterUrl', () => { + it('returns null for undefined or empty', () => { + expect(getPosterUrl(undefined)).toBeNull(); + expect(getPosterUrl('')).toBeNull(); + }); + + it('prepends base url for paths starting with /', () => { + expect(getPosterUrl('/poster.jpg')).toBe('https://image.tmdb.org/t/p/w342/poster.jpg'); + }); + + it('returns absolute URL as is', () => { + expect(getPosterUrl('https://example.com/a.jpg')).toBe('https://example.com/a.jpg'); + expect(getPosterUrl('http://example.com/a.jpg')).toBe('http://example.com/a.jpg'); + }); + + it('returns null for invalid non-URL strings without slash', () => { + expect(getPosterUrl('not a url')).toBeNull(); + expect(getPosterUrl('poster.jpg')).toBeNull(); + }); +}); + +describe('getPlaceholderUrl', () => { + it('returns base64 data url', () => { + const url = getPlaceholderUrl(); + expect(url.startsWith('data:image/svg+xml;base64,')).toBe(true); + const decoded = atob(url.split(',')[1]); + expect(decoded).toContain(' Date: Fri, 28 Aug 2026 07:29:38 +0200 Subject: [PATCH 09/11] fix: address review comments for add-item reset, image-storage spec, and type inference Clear episodic season/episode data before loading replacement details in AddItemComponent to prevent stale seasons when a suggestion's detail fetch fails. Make image-version subscription test instance-specific with a unique blob URL assertion. Remove redundant Suggestion[] annotation to follow inferred types guideline. --- .../add-item/add-item.component.spec.ts | 16 +++++++++++----- .../components/add-item/add-item.component.ts | 8 ++++++++ src/app/services/image-storage.service.spec.ts | 4 +++- src/app/services/suggestion-search.service.ts | 2 +- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/app/components/add-item/add-item.component.spec.ts b/src/app/components/add-item/add-item.component.spec.ts index 57de271..9fbbeaf 100644 --- a/src/app/components/add-item/add-item.component.spec.ts +++ b/src/app/components/add-item/add-item.component.spec.ts @@ -145,7 +145,7 @@ describe('AddItemComponent', () => { expect(suggestionSearchService.getDetails).toHaveBeenCalledWith({ source: 'tmdb', id: 1396 }); expect(fixture.componentInstance.autofillPatch()).toEqual({ - id: 1, + id: 2, value: { seasons: [ { @@ -185,7 +185,7 @@ describe('AddItemComponent', () => { expect(suggestionSearchService.getDetails).toHaveBeenCalledWith({ source: 'jikan', id: 999 }); expect(fixture.componentInstance.autofillPatch()).toEqual({ - id: 1, + id: 2, value: { seasons: [ { @@ -213,7 +213,10 @@ describe('AddItemComponent', () => { ); expect(suggestionSearchService.getDetails).not.toHaveBeenCalled(); - expect(fixture.componentInstance.autofillPatch()).toBeNull(); + expect(fixture.componentInstance.autofillPatch()).toEqual({ + id: 1, + value: { season: 1, episode: 1, seasons: [] }, + }); }); it('does not apply stale TMDB details after the title changes', () => { @@ -299,7 +302,10 @@ describe('AddItemComponent', () => { ], }); - expect(fixture.componentInstance.autofillPatch()).toBeNull(); + expect(fixture.componentInstance.autofillPatch()).toEqual({ + id: 2, + value: { season: 1, episode: 1, seasons: [] }, + }); }); it('cancels the previous TMDB details request when another series is selected', () => { @@ -344,7 +350,7 @@ describe('AddItemComponent', () => { expect(suggestionSearchService.getDetails).toHaveBeenCalledWith({ source: 'tmdb', id: 1396 }); expect(suggestionSearchService.getDetails).toHaveBeenCalledWith({ source: 'tmdb', id: 66732 }); expect(fixture.componentInstance.autofillPatch()).toEqual({ - id: 2, + id: 4, value: { seasons: [ { diff --git a/src/app/components/add-item/add-item.component.ts b/src/app/components/add-item/add-item.component.ts index eb4380a..b19e761 100644 --- a/src/app/components/add-item/add-item.component.ts +++ b/src/app/components/add-item/add-item.component.ts @@ -155,10 +155,18 @@ export class AddItemComponent { this.suggestionsError.set(''); if (!isEpisodicType(suggestion.type)) { + this.autofillPatch.set({ + id: ++this.autofillPatchId, + value: { season: 1, episode: 1, seasons: [] }, + }); this.selectedEpisodicRef.next(null); return; } + this.autofillPatch.set({ + id: ++this.autofillPatchId, + value: { season: 1, episode: 1, seasons: [] }, + }); this.selectedEpisodicRef.next({ source: suggestion.source, id: suggestion.id }); } diff --git a/src/app/services/image-storage.service.spec.ts b/src/app/services/image-storage.service.spec.ts index 104293b..145cce7 100644 --- a/src/app/services/image-storage.service.spec.ts +++ b/src/app/services/image-storage.service.spec.ts @@ -316,6 +316,8 @@ describe('ImageStorageService', () => { }); it('handles imageVersion subscription', async () => { + const uniqueUrl = 'blob:unique-image-version-test'; + URL.createObjectURL = vi.fn(() => uniqueUrl) as unknown as typeof URL.createObjectURL; const service = new ImageStorageService(); const blob = createImageBlob('abc', 'image/png'); const id = await service.storeFile(blob); @@ -324,6 +326,6 @@ describe('ImageStorageService', () => { imagesInvalidated.next(); await new Promise((r) => setTimeout(r, 0)); expect(imageVersion()).toBeGreaterThan(before); - expect(URL.revokeObjectURL).toHaveBeenCalled(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith(uniqueUrl); }); }); diff --git a/src/app/services/suggestion-search.service.ts b/src/app/services/suggestion-search.service.ts index 32b8054..6434e3b 100644 --- a/src/app/services/suggestion-search.service.ts +++ b/src/app/services/suggestion-search.service.ts @@ -29,7 +29,7 @@ export class SuggestionSearchService { anilist: this.anilist.search(trimmed).pipe(catchError(() => of([] as Suggestion[]))), }).pipe( map(({ tmdb, jikan, anilist }) => { - const merged: Suggestion[] = [...tmdb, ...jikan, ...anilist]; + const merged = [...tmdb, ...jikan, ...anilist]; return merged.slice(0, SUGGESTION_MERGED_LIMIT); }), ); From 808773cb89a7e6f17ea60be43ce54eeb2d1684a2 Mon Sep 17 00:00:00 2001 From: CodeWithMaBot <310216433+CodeWithMaBot@users.noreply.github.com> Date: Fri, 28 Aug 2026 07:36:59 +0200 Subject: [PATCH 10/11] refactor(settings): consolidate suggestion sources into concise layout Merge AniList and Jikan sections into single Anime Suggestions card and trim TMDB helper copy to reduce text heaviness --- .../components/settings/settings.component.ts | 101 ++++++------------ 1 file changed, 35 insertions(+), 66 deletions(-) diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index 27ca00c..2e99c78 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -47,7 +47,7 @@ import { environment } from '../../../environments/environment'; class="form-control" />

- Preferred for fetching movie and series suggestions while adding items. + Preferred for movie & series suggestions.

@@ -63,7 +63,7 @@ import { environment } from '../../../environments/environment'; class="form-control" />

- Used as a fallback when no read access token is saved. + Fallback if no token is set.

@@ -87,85 +87,54 @@ import { environment } from '../../../environments/environment';

- AniList + Anime Suggestions

-
+

AniList - AniList.co - -

- This product uses the AniList API (graphql.anilist.co) but is not endorsed or certified - by AniList. -

-

- Anime suggestions (TV, Movie, OVA, ONA) are fetched from AniList’s public GraphQL API - with - isAdult:false filtering and no authentication required. - See - rate limiting docs - for limits. -

-
-
- -
-

- Jikan (MyAnimeList) -

-
+ & Jikan - Jikan.moe - - via - MyAnimeList) — public APIs, no setup required. +

+

+ TV / Movie / OVA / ONA · SFW-filtered · + AniList limits - MyAnimeList.net - -

- This product uses the - Jikan API - (api.jikan.moe) which provides data from MyAnimeList but is not endorsed or certified by - MyAnimeList or Jikan. -

-

- Anime suggestions (TV, Movie, OVA, ONA) are fetched from Jikan’s public REST API with - sfw:true filtering and no authentication required. See - Jikan docs - for limits (3 requests/second). -

-
+ · + Jikan docs + (3 req/s) +

+

+ Not endorsed by AniList or MyAnimeList / Jikan. +

From 7bee5accebc16ace8b1e0598a8606e2259b1d3d2 Mon Sep 17 00:00:00 2001 From: CodeWithMaBot <310216433+CodeWithMaBot@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:08:24 +0200 Subject: [PATCH 11/11] test(item-form): mock SuggestionSearchService to fix poster-picker injection --- src/app/components/item-form/item-form.component.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/item-form/item-form.component.spec.ts b/src/app/components/item-form/item-form.component.spec.ts index d75cb2b..a6dfc17 100644 --- a/src/app/components/item-form/item-form.component.spec.ts +++ b/src/app/components/item-form/item-form.component.spec.ts @@ -1,7 +1,7 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Group } from '../../models/group.model'; -import { TmdbSuggestionService } from '../../services/tmdb-suggestion.service'; +import { SuggestionSearchService } from '../../services/suggestion-search.service'; import { ImageStorageService } from '../../services/image-storage.service'; import { ItemFormComponent } from './item-form.component'; import { vi } from 'vitest'; @@ -20,7 +20,7 @@ describe('ItemFormComponent', () => { TestBed.configureTestingModule({ providers: [ { - provide: TmdbSuggestionService, + provide: SuggestionSearchService, useValue: { search: vi.fn(() => of([])), },