chore: reformat code

This commit is contained in:
Phan An 2024-04-23 23:01:27 +02:00
parent 49eb577315
commit 7071343215
199 changed files with 1257 additions and 1290 deletions

View file

@ -30,7 +30,7 @@
<AcceptInvitation v-if="layout === 'invitation'" />
<ResetPasswordForm v-if="layout === 'reset-password'" />
<AppInitializer v-if="authenticated" @success="onInitSuccess" @error="onInitError" />
<AppInitializer v-if="authenticated" @error="onInitError" @success="onInitSuccess" />
</template>
<script lang="ts" setup>
@ -151,7 +151,7 @@ provide(CurrentSongKey, currentSong)
<style lang="postcss">
#dragGhost {
@apply inline-block py-2 px-3 rounded-md text-base font-sans fixed top-0 left-0 z-[-1] bg-k-success
@apply inline-block py-2 pl-8 pr-3 rounded-md text-base font-sans fixed top-0 left-0 z-[-1] bg-k-success
text-k-text-primary no-hover:hidden;
}

View file

@ -27,10 +27,18 @@ const deepMerge = (first: object, second: object) => {
})
}
const setPropIfNotExists = (obj: object | null, prop: any, value: any) => {
if (!obj) return
if (!Object.prototype.hasOwnProperty.call(obj, prop)) {
obj[prop] = value
}
}
export default abstract class UnitTestCase {
private backupMethods = new Map()
protected router: Router
protected user: UserEvent
private backupMethods = new Map()
public constructor () {
this.router = new Router(routes)
@ -108,37 +116,6 @@ export default abstract class UnitTestCase {
}, this.supplyRequiredProvides(options)))
}
private supplyRequiredProvides (options: RenderOptions) {
options.global = options.global || {}
options.global.provide = options.global.provide || {}
// @ts-ignore
if (!options.global.provide?.hasOwnProperty(DialogBoxKey)) {
// @ts-ignore
options.global.provide[DialogBoxKey] = DialogBoxStub
}
// @ts-ignore
if (!options.global.provide?.hasOwnProperty(MessageToasterKey)) {
// @ts-ignore
options.global.provide[MessageToasterKey] = MessageToasterStub
}
// @ts-ignore
if (!options.global.provide.hasOwnProperty(OverlayKey)) {
// @ts-ignore
options.global.provide[OverlayKey] = OverlayStub
}
// @ts-ignore
if (!options.global.provide.hasOwnProperty(RouterKey)) {
// @ts-ignore
options.global.provide[RouterKey] = this.router
}
return options
}
protected enablePlusEdition () {
commonStore.state.koel_plus = {
active: true,
@ -189,9 +166,21 @@ export default abstract class UnitTestCase {
await this.user.type(element, value)
}
protected async trigger(element: HTMLElement, key: EventType | string, options?: {}) {
protected async trigger (element: HTMLElement, key: EventType | string, options?: {}) {
await fireEvent(element, createEvent[key](element, options))
}
protected abstract test ()
private supplyRequiredProvides (options: RenderOptions) {
options.global = options.global || {}
options.global.provide = options.global.provide || {}
setPropIfNotExists(options.global.provide, DialogBoxKey, DialogBoxStub)
setPropIfNotExists(options.global.provide, MessageToasterKey, MessageToasterStub)
setPropIfNotExists(options.global.provide, OverlayKey, OverlayStub)
setPropIfNotExists(options.global.provide, RouterKey, this.router)
return options
}
}

View file

@ -10,26 +10,6 @@ import AlbumCard from './AlbumCard.vue'
let album: Album
new class extends UnitTestCase {
private renderComponent () {
album = factory<Album>('album', {
id: 42,
name: 'IV',
artist_id: 17,
artist_name: 'Led Zeppelin'
})
return this.render(AlbumCard, {
props: {
album
},
global: {
stubs: {
AlbumArtistThumbnail: this.stub('thumbnail')
}
}
})
}
protected test () {
it('renders', () => expect(this.renderComponent().html()).toMatchSnapshot())
@ -70,4 +50,24 @@ new class extends UnitTestCase {
expect(emitMock).toHaveBeenCalledWith('ALBUM_CONTEXT_MENU_REQUESTED', expect.any(MouseEvent), album)
})
}
private renderComponent () {
album = factory<Album>('album', {
id: 42,
name: 'IV',
artist_id: 17,
artist_name: 'Led Zeppelin'
})
return this.render(AlbumCard, {
props: {
album
},
global: {
stubs: {
AlbumArtistThumbnail: this.stub('thumbnail')
}
}
})
}
}

View file

@ -2,8 +2,8 @@
<ArtistAlbumCard
v-if="showing"
:entity="album"
:title="`${album.name} by ${album.artist_name}`"
:layout="layout"
:title="`${album.name} by ${album.artist_name}`"
@contextmenu="requestContextMenu"
@dblclick="shuffle"
@dragstart="onDragStart"

View file

@ -11,18 +11,6 @@ import AlbumContextMenu from './AlbumContextMenu.vue'
let album: Album
new class extends UnitTestCase {
private async renderComponent (_album?: Album) {
album = _album || factory<Album>('album', {
name: 'IV'
})
const rendered = this.render(AlbumContextMenu)
eventBus.emit('ALBUM_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, album)
await this.tick(2)
return rendered
}
protected test () {
it('renders', async () => expect((await this.renderComponent()).html()).toMatchSnapshot())
@ -94,4 +82,16 @@ new class extends UnitTestCase {
expect(mock).toHaveBeenCalledWith(`artist/${album.artist_id}`)
})
}
private async renderComponent (_album?: Album) {
album = _album || factory<Album>('album', {
name: 'IV'
})
const rendered = this.render(AlbumContextMenu)
eventBus.emit('ALBUM_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, album)
await this.tick(2)
return rendered
}
}

View file

@ -9,6 +9,22 @@ import AlbumInfoComponent from './AlbumInfo.vue'
let album: Album
new class extends UnitTestCase {
protected test () {
it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async (mode) => {
await this.renderComponent(mode)
screen.getByTestId('album-info-tracks')
if (mode === 'aside') {
screen.getByTestId('thumbnail')
} else {
expect(screen.queryByTestId('thumbnail')).toBeNull()
}
expect(screen.getByTestId('album-info').classList.contains(mode)).toBe(true)
})
}
private async renderComponent (mode: MediaInfoDisplayMode = 'aside', info?: AlbumInfo) {
commonStore.state.uses_last_fm = true
@ -37,20 +53,4 @@ new class extends UnitTestCase {
return rendered
}
protected test () {
it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async (mode) => {
await this.renderComponent(mode)
screen.getByTestId('album-info-tracks')
if (mode === 'aside') {
screen.getByTestId('thumbnail')
} else {
expect(screen.queryByTestId('thumbnail')).toBeNull()
}
expect(screen.getByTestId('album-info').classList.contains(mode)).toBe(true)
})
}
}

View file

@ -19,8 +19,8 @@
v-if="info.tracks?.length"
:album="album"
:tracks="info.tracks"
data-testid="album-info-tracks"
class="mt-8"
data-testid="album-info-tracks"
/>
</template>

View file

@ -6,8 +6,8 @@
<li
v-for="(track, index) in tracks"
:key="index"
data-testid="album-track-item"
class="flex p-2 before:w-7 before:opacity-50"
data-testid="album-track-item"
>
<TrackListItem :album="album" :track="track" />
</li>

View file

@ -9,6 +9,23 @@ import { ref } from 'vue'
import AlbumTrackListItem from './AlbumTrackListItem.vue'
new class extends UnitTestCase {
protected test () {
it('renders', () => expect(this.renderComponent().html()).toMatchSnapshot())
it('plays', async () => {
const matchedSong = factory<Song>('song')
const queueMock = this.mock(queueStore, 'queueIfNotQueued')
const playMock = this.mock(playbackService, 'play')
this.renderComponent(matchedSong)
await this.user.click(screen.getByTitle('Click to play'))
expect(queueMock).toHaveBeenNthCalledWith(1, matchedSong)
expect(playMock).toHaveBeenNthCalledWith(1, matchedSong)
})
}
private renderComponent (matchedSong?: Song) {
const songsToMatchAgainst = factory<Song>('song', 10)
const album = factory<Album>('album')
@ -36,21 +53,4 @@ new class extends UnitTestCase {
return rendered
}
protected test () {
it('renders', () => expect(this.renderComponent().html()).toMatchSnapshot())
it('plays', async () => {
const matchedSong = factory<Song>('song')
const queueMock = this.mock(queueStore, 'queueIfNotQueued')
const playMock = this.mock(playbackService, 'play')
this.renderComponent(matchedSong)
await this.user.click(screen.getByTitle('Click to play'))
expect(queueMock).toHaveBeenNthCalledWith(1, matchedSong)
expect(playMock).toHaveBeenNthCalledWith(1, matchedSong)
})
}
}

View file

@ -1,8 +1,8 @@
<template>
<div
class="track-list-item flex flex-1 gap-1"
:class="{ active, available: matchedSong }"
:title="tooltip"
class="track-list-item flex flex-1 gap-1"
tabindex="0"
@click="play"
>

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<article data-v-f01bdc56="" class="relative flex max-w-full md:max-w-[256px] border p-5 rounded-lg flex-col gap-5 transition border-color duration-200 full" draggable="true" tabindex="0" data-testid="artist-album-card" title="IV by Led Zeppelin">
<article data-v-f01bdc56="" class="full relative flex max-w-full md:max-w-[256px] border p-5 rounded-lg flex-col gap-5 transition border-color duration-200" data-testid="artist-album-card" draggable="true" tabindex="0" title="IV by Led Zeppelin">
<div data-v-a14c1d10="" data-v-f01bdc56="" class="cover relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md after:block after:pt-[100%]" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-a14c1d10="" alt="IV" src="http://loremflickr.com/640/480" class="w-full h-full object-cover absolute left-0 top-0 pointer-events-none before:absolute before:w-full before:h-full before:opacity-0 before:z-[1] before-top-0" loading="lazy"><a data-v-a14c1d10="" class="control control-play h-full w-full absolute flex justify-center items-center" role="button"><span data-v-a14c1d10="" class="hidden">Play all songs in the album IV</span><span data-v-a14c1d10="" class="icon opacity-0 w-1/2 h-1/2 flex justify-center items-center pointer-events-none pl-[4%] rounded-full after:w-full after:h-full"></span></a></div>
<footer data-v-f01bdc56="" class="flex flex-1 flex-col gap-1.5 overflow-hidden">
<div data-v-f01bdc56="" class="name flex flex-col gap-2 whitespace-nowrap"><a href="#/album/42" class="font-medium" data-testid="name">IV</a><a href="#/artist/17">Led Zeppelin</a></div>

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<nav data-v-0408531a="" class="album-menu menu context-menu select-none" style="top: 42px; left: 420px;" tabindex="0" data-testid="album-context-menu">
<nav data-v-0408531a="" class="album-menu menu context-menu select-none" tabindex="0" data-testid="album-context-menu">
<ul data-v-0408531a="">
<li>Play All</li>
<li>Shuffle All</li>

View file

@ -19,19 +19,6 @@ new class extends UnitTestCase {
})
}
private renderComponent () {
return this.render(ArtistCard, {
props: {
artist
},
global: {
stubs: {
AlbumArtistThumbnail: this.stub('thumbnail')
}
}
})
}
protected test () {
it('renders', () => expect(this.renderComponent().html()).toMatchSnapshot())
@ -72,4 +59,17 @@ new class extends UnitTestCase {
expect(emitMock).toHaveBeenCalledWith('ARTIST_CONTEXT_MENU_REQUESTED', expect.any(MouseEvent), artist)
})
}
private renderComponent () {
return this.render(ArtistCard, {
props: {
artist
},
global: {
stubs: {
AlbumArtistThumbnail: this.stub('thumbnail')
}
}
})
}
}

View file

@ -11,18 +11,6 @@ import ArtistContextMenu from './ArtistContextMenu.vue'
let artist: Artist
new class extends UnitTestCase {
private async renderComponent (_artist?: Artist) {
artist = _artist || factory<Artist>('artist', {
name: 'Accept'
})
const rendered = this.render(ArtistContextMenu)
eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, artist)
await this.tick(2)
return rendered
}
protected test () {
it('renders', async () => expect((await this.renderComponent()).html()).toMatchSnapshot())
@ -91,4 +79,16 @@ new class extends UnitTestCase {
expect(screen.queryByText('Download')).toBeNull()
})
}
private async renderComponent (_artist?: Artist) {
artist = _artist || factory<Artist>('artist', {
name: 'Accept'
})
const rendered = this.render(ArtistContextMenu)
eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, artist)
await this.tick(2)
return rendered
}
}

View file

@ -9,6 +9,20 @@ import ArtistInfoComponent from './ArtistInfo.vue'
let artist: Artist
new class extends UnitTestCase {
protected test () {
it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async (mode) => {
await this.renderComponent(mode)
if (mode === 'aside') {
screen.getByTestId('thumbnail')
} else {
expect(screen.queryByTestId('thumbnail')).toBeNull()
}
expect(screen.getByTestId('artist-info').classList.contains(mode)).toBe(true)
})
}
private async renderComponent (mode: MediaInfoDisplayMode = 'aside', info?: ArtistInfo) {
commonStore.state.uses_last_fm = true
info = info ?? factory<ArtistInfo>('artist-info')
@ -33,18 +47,4 @@ new class extends UnitTestCase {
return rendered
}
protected test () {
it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async (mode) => {
await this.renderComponent(mode)
if (mode === 'aside') {
screen.getByTestId('thumbnail')
} else {
expect(screen.queryByTestId('thumbnail')).toBeNull()
}
expect(screen.getByTestId('artist-info').classList.contains(mode)).toBe(true)
})
}
}

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<article data-v-f01bdc56="" class="relative flex max-w-full md:max-w-[256px] border p-5 rounded-lg flex-col gap-5 transition border-color duration-200 full" draggable="true" tabindex="0" data-testid="artist-album-card" title="Led Zeppelin">
<article data-v-f01bdc56="" class="full relative flex max-w-full md:max-w-[256px] border p-5 rounded-lg flex-col gap-5 transition border-color duration-200" data-testid="artist-album-card" draggable="true" tabindex="0" title="Led Zeppelin">
<div data-v-a14c1d10="" data-v-f01bdc56="" class="cover relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md after:block after:pt-[100%]" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-a14c1d10="" alt="Led Zeppelin" src="foo.jpg" class="w-full h-full object-cover absolute left-0 top-0 pointer-events-none before:absolute before:w-full before:h-full before:opacity-0 before:z-[1] before-top-0" loading="lazy"><a data-v-a14c1d10="" class="control control-play h-full w-full absolute flex justify-center items-center" role="button"><span data-v-a14c1d10="" class="hidden">Play all songs by Led Zeppelin</span><span data-v-a14c1d10="" class="icon opacity-0 w-1/2 h-1/2 flex justify-center items-center pointer-events-none pl-[4%] rounded-full after:w-full after:h-full"></span></a></div>
<footer data-v-f01bdc56="" class="flex flex-1 flex-col gap-1.5 overflow-hidden">
<div data-v-f01bdc56="" class="name flex flex-col gap-2 whitespace-nowrap"><a href="#/artist/42" class="font-medium" data-testid="name">Led Zeppelin</a></div>

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<nav data-v-0408531a="" class="artist-menu menu context-menu select-none" style="top: 42px; left: 420px;" tabindex="0" data-testid="artist-context-menu">
<nav data-v-0408531a="" class="artist-menu menu context-menu select-none" tabindex="0" data-testid="artist-context-menu">
<ul data-v-0408531a="">
<li>Play All</li>
<li>Shuffle All</li>

View file

@ -10,18 +10,18 @@
<div class="flex flex-col gap-3 sm:flex-row sm:gap-0 sm:content-stretch">
<TextInput
v-model="email"
placeholder="Your email address"
required type="email"
class="flex-1 sm:rounded-l sm:rounded-r-none"
placeholder="Your email address" required
type="email"
/>
<Btn :disabled="loading" type="submit" class="sm:rounded-l-none sm:rounded-r">Reset Password</Btn>
<Btn :disabled="loading" class="sm:rounded-l-none sm:rounded-r" type="submit">Reset Password</Btn>
<Btn :disabled="loading" class="!text-k-text-secondary" transparent @click="cancel">Cancel</Btn>
</div>
</FormRow>
</form>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { ref } from 'vue'
import { authService } from '@/services'
import { useErrorHandler, useMessageToaster } from '@/composables'

View file

@ -6,18 +6,6 @@ import { logger } from '@/utils'
import LoginFrom from './LoginForm.vue'
new class extends UnitTestCase {
private async submitForm (loginMock: Mock) {
const rendered = this.render(LoginFrom)
await this.type(screen.getByPlaceholderText('Email Address'), 'john@doe.com')
await this.type(screen.getByPlaceholderText('Password'), 'secret')
await this.user.click(screen.getByTestId('submit'))
expect(loginMock).toHaveBeenCalledWith('john@doe.com', 'secret')
return rendered
}
protected test () {
it('renders', () => expect(this.render(LoginFrom).html()).toMatchSnapshot())
@ -67,4 +55,16 @@ new class extends UnitTestCase {
window.SSO_PROVIDERS = []
})
}
private async submitForm (loginMock: Mock) {
const rendered = this.render(LoginFrom)
await this.type(screen.getByPlaceholderText('Email Address'), 'john@doe.com')
await this.type(screen.getByPlaceholderText('Password'), 'secret')
await this.user.click(screen.getByTestId('submit'))
expect(loginMock).toHaveBeenCalledWith('john@doe.com', 'secret')
return rendered
}
}

View file

@ -2,13 +2,13 @@
<div class="flex items-center justify-center min-h-screen my-0 mx-auto flex-col gap-5">
<form
v-show="!showingForgotPasswordForm"
class="w-full sm:w-[288px] sm:border duration-500 p-7 rounded-xl border-transparent sm:bg-white/10 space-y-3"
:class="{ error: failed }"
class="w-full sm:w-[288px] sm:border duration-500 p-7 rounded-xl border-transparent sm:bg-white/10 space-y-3"
data-testid="login-form"
@submit.prevent="login"
>
<div class="text-center mb-8">
<img class="inline-block" alt="Koel's logo" src="@/../img/logo.svg" width="156">
<img alt="Koel's logo" class="inline-block" src="@/../img/logo.svg" width="156">
</div>
<FormRow>
@ -20,7 +20,7 @@
</FormRow>
<FormRow>
<Btn type="submit" data-testid="submit">Log In</Btn>
<Btn data-testid="submit" type="submit">Log In</Btn>
</FormRow>
<FormRow v-if="canResetPassword">

View file

@ -17,7 +17,7 @@
</div>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { authService } from '@/services'
import { base64Decode } from '@/utils'

View file

@ -3,12 +3,12 @@
exports[`renders 1`] = `
<div data-v-0b0f87ea="" class="flex items-center justify-center min-h-screen my-0 mx-auto flex-col gap-5">
<form data-v-0b0f87ea="" class="w-full sm:w-[288px] sm:border duration-500 p-7 rounded-xl border-transparent sm:bg-white/10 space-y-3" data-testid="login-form">
<div data-v-0b0f87ea="" class="text-center mb-8"><img data-v-0b0f87ea="" class="inline-block" alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="156"></div><label data-v-0b0f87ea="" class="flex flex-col gap-2 text-[1.1rem]">
<div data-v-0b0f87ea="" class="text-center mb-8"><img data-v-0b0f87ea="" alt="Koel's logo" class="inline-block" src="undefined/resources/assets/img/logo.svg" width="156"></div><label data-v-0b0f87ea="" class="flex flex-col gap-2 text-[1.1rem]">
<!--v-if--><input data-v-0b0f87ea="" class="block text-base w-full px-4 py-2.5 rounded bg-k-bg-input text-k-text-input read-only:bg-gray-400 read-only:text-gray-900 disabled:bg-gray-400 disabled:text-gray-900" type="email" autofocus="" placeholder="Email Address" required="">
<!--v-if-->
</label><label data-v-0b0f87ea="" class="flex flex-col gap-2 text-[1.1rem]">
<!--v-if-->
<div data-v-0b0f87ea="" class="relative"><input class="block text-base w-full px-4 py-2.5 rounded bg-k-bg-input text-k-text-input read-only:bg-gray-400 read-only:text-gray-900 disabled:bg-gray-400 disabled:text-gray-900 w-full" type="password" data-testid="input" placeholder="Password" required=""><button class="absolute p-2.5 right-0 top-0 text-k-bg-primary" type="button" data-testid="toggle"><br data-testid="Icon" icon="[object Object]"></button></div>
<div data-v-0b0f87ea="" class="relative"><input class="block text-base w-full px-4 py-2.5 rounded bg-k-bg-input text-k-text-input read-only:bg-gray-400 read-only:text-gray-900 disabled:bg-gray-400 disabled:text-gray-900 w-full" type="password" data-testid="input" placeholder="Password" required=""><button class="absolute p-2.5 right-0 top-0 text-k-bg-primary" data-testid="toggle" type="button"><br data-testid="Icon" icon="[object Object]"></button></div>
<!--v-if-->
</label><label data-v-0b0f87ea="" class="flex flex-col gap-2 text-[1.1rem]">
<!--v-if--><button data-v-8943c846="" data-v-0b0f87ea="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer" type="submit" data-testid="submit">Log In</button>
@ -26,12 +26,12 @@ exports[`renders 1`] = `
exports[`shows Google login button 1`] = `
<div data-v-0b0f87ea="" class="flex items-center justify-center min-h-screen my-0 mx-auto flex-col gap-5">
<form data-v-0b0f87ea="" class="w-full sm:w-[288px] sm:border duration-500 p-7 rounded-xl border-transparent sm:bg-white/10 space-y-3" data-testid="login-form">
<div data-v-0b0f87ea="" class="text-center mb-8"><img data-v-0b0f87ea="" class="inline-block" alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="156"></div><label data-v-0b0f87ea="" class="flex flex-col gap-2 text-[1.1rem]">
<div data-v-0b0f87ea="" class="text-center mb-8"><img data-v-0b0f87ea="" alt="Koel's logo" class="inline-block" src="undefined/resources/assets/img/logo.svg" width="156"></div><label data-v-0b0f87ea="" class="flex flex-col gap-2 text-[1.1rem]">
<!--v-if--><input data-v-0b0f87ea="" class="block text-base w-full px-4 py-2.5 rounded bg-k-bg-input text-k-text-input read-only:bg-gray-400 read-only:text-gray-900 disabled:bg-gray-400 disabled:text-gray-900" type="email" autofocus="" placeholder="Email Address" required="">
<!--v-if-->
</label><label data-v-0b0f87ea="" class="flex flex-col gap-2 text-[1.1rem]">
<!--v-if-->
<div data-v-0b0f87ea="" class="relative"><input class="block text-base w-full px-4 py-2.5 rounded bg-k-bg-input text-k-text-input read-only:bg-gray-400 read-only:text-gray-900 disabled:bg-gray-400 disabled:text-gray-900 w-full" type="password" data-testid="input" placeholder="Password" required=""><button class="absolute p-2.5 right-0 top-0 text-k-bg-primary" type="button" data-testid="toggle"><br data-testid="Icon" icon="[object Object]"></button></div>
<div data-v-0b0f87ea="" class="relative"><input class="block text-base w-full px-4 py-2.5 rounded bg-k-bg-input text-k-text-input read-only:bg-gray-400 read-only:text-gray-900 disabled:bg-gray-400 disabled:text-gray-900 w-full" type="password" data-testid="input" placeholder="Password" required=""><button class="absolute p-2.5 right-0 top-0 text-k-bg-primary" data-testid="toggle" type="button"><br data-testid="Icon" icon="[object Object]"></button></div>
<!--v-if-->
</label><label data-v-0b0f87ea="" class="flex flex-col gap-2 text-[1.1rem]">
<!--v-if--><button data-v-8943c846="" data-v-0b0f87ea="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer" type="submit" data-testid="submit">Log In</button>

View file

@ -5,11 +5,11 @@
type="button"
@click.prevent="loginWithGoogle"
>
<img :src="googleLogo" alt="Google Logo" width="32" height="32">
<img :src="googleLogo" alt="Google Logo" height="32" width="32">
</button>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import googleLogo from '@/../img/logos/google.svg'
import { openPopup } from '@/utils'

View file

@ -41,7 +41,7 @@
</div>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { invitationService } from '@/services'
import { useErrorHandler, useRouter } from '@/composables'

View file

@ -17,9 +17,9 @@ new class extends UnitTestCase {
})
await this.router.activateRoute({
path: '_',
screen: 'Invitation.Accept'
}, {
path: '_',
screen: 'Invitation.Accept'
}, {
token: 'my-token'
})

View file

@ -5,10 +5,6 @@ import { plusService } from '@/services'
import Form from './ActivateLicenseForm.vue'
new class extends UnitTestCase {
private renderComponent () {
return this.render(Form )
}
protected test () {
it('activates license', async () => {
this.renderComponent()
@ -19,4 +15,8 @@ new class extends UnitTestCase {
expect(activateMock).toHaveBeenCalledWith('my-license-key')
})
}
private renderComponent () {
return this.render(Form)
}
}

View file

@ -12,7 +12,7 @@
<Btn :disabled="loading" class="!rounded-l-none" type="submit">Activate</Btn>
</form>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { ref } from 'vue'
import { plusService } from '@/services'
import { forceReloadWindow } from '@/utils'

View file

@ -8,7 +8,7 @@
</Btn>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { faPlus } from '@fortawesome/free-solid-svg-icons'
import { eventBus } from '@/utils'

View file

@ -5,10 +5,6 @@ import UnitTestCase from '@/__tests__/UnitTestCase'
import Modal from './KoelPlusModal.vue'
new class extends UnitTestCase {
private renderComponent () {
return this.render(Modal)
}
protected test () {
it('shows button to purchase Koel Plus', async () => {
commonStore.state.koel_plus.product_id = '42'
@ -29,4 +25,8 @@ new class extends UnitTestCase {
screen.getByTestId('activateForm')
})
}
private renderComponent () {
return this.render(Modal)
}
}

View file

@ -1,8 +1,8 @@
<template>
<div class="plus text-k-text-secondary max-w-[480px] flex flex-col items-center" data-testid="koel-plus" tabindex="0">
<img
class="-mt-[48px] rounded-full border-[6px] border-white"
alt="Koel Plus"
class="-mt-[48px] rounded-full border-[6px] border-white"
src="@/../img/koel-plus.svg"
width="96"
>
@ -30,12 +30,12 @@
</main>
<footer class="w-full text-center bg-black/20">
<Btn data-testid="close-modal-btn" danger rounded @click.prevent="close">Close</Btn>
<Btn danger data-testid="close-modal-btn" rounded @click.prevent="close">Close</Btn>
</footer>
</div>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { useKoelPlus } from '@/composables'

View file

@ -5,17 +5,6 @@ import { eventBus } from '@/utils'
import FooterExtraControls from './FooterExtraControls.vue'
new class extends UnitTestCase {
private renderComponent () {
return this.render(FooterExtraControls, {
global: {
stubs: {
Equalizer: this.stub('Equalizer'),
Volume: this.stub('Volume')
}
}
})
}
protected test () {
it('renders', () => {
this.setReadOnlyProperty(document, 'fullscreenEnabled', undefined)
@ -32,4 +21,15 @@ new class extends UnitTestCase {
expect(emitMock).toHaveBeenCalledWith('FULLSCREEN_TOGGLE')
})
}
private renderComponent () {
return this.render(FooterExtraControls, {
global: {
stubs: {
Equalizer: this.stub('Equalizer'),
Volume: this.stub('Volume')
}
}
})
}
}

View file

@ -8,6 +8,29 @@ import { screen } from '@testing-library/vue'
import FooterPlaybackControls from './FooterPlaybackControls.vue'
new class extends UnitTestCase {
protected test () {
it('renders without a current song', () => expect(this.renderComponent(null).html()).toMatchSnapshot())
it('renders with a current song', () => expect(this.renderComponent().html()).toMatchSnapshot())
it('plays the previous song', async () => {
const playMock = this.mock(playbackService, 'playPrev')
this.renderComponent()
await this.user.click(screen.getByRole('button', { name: 'Play previous song' }))
expect(playMock).toHaveBeenCalled()
})
it('plays the next song', async () => {
const playMock = this.mock(playbackService, 'playNext')
this.renderComponent()
await this.user.click(screen.getByRole('button', { name: 'Play next song' }))
expect(playMock).toHaveBeenCalled()
})
}
private renderComponent (song?: Song | null) {
if (song === undefined) {
song = factory<Song>('song', {
@ -32,27 +55,4 @@ new class extends UnitTestCase {
}
})
}
protected test () {
it('renders without a current song', () => expect(this.renderComponent(null).html()).toMatchSnapshot())
it('renders with a current song', () => expect(this.renderComponent().html()).toMatchSnapshot())
it('plays the previous song', async () => {
const playMock = this.mock(playbackService, 'playPrev')
this.renderComponent()
await this.user.click(screen.getByRole('button', { name: 'Play previous song' }))
expect(playMock).toHaveBeenCalled()
})
it('plays the next song', async () => {
const playMock = this.mock(playbackService, 'playNext')
this.renderComponent()
await this.user.click(screen.getByRole('button', { name: 'Play next song' }))
expect(playMock).toHaveBeenCalled()
})
}
}

View file

@ -9,8 +9,8 @@
<div v-if="song" class="meta overflow-hidden hidden md:block">
<h3 class="title text-ellipsis overflow-hidden whitespace-nowrap">{{ song.title }}</h3>
<a
class="artist text-ellipsis overflow-hidden whitespace-nowrap block text-[0.9rem] !text-k-text-secondary hover:!text-k-accent"
:href="`/#/artist/${song.artist_id}`"
class="artist text-ellipsis overflow-hidden whitespace-nowrap block text-[0.9rem] !text-k-text-secondary hover:!text-k-accent"
>
{{ song.artist_name }}
</a>
@ -29,7 +29,7 @@ const { startDragging } = useDraggable('songs')
const song = requireInjection(CurrentSongKey, ref())
const cover = computed(() => song.value?.album_cover || defaultCover)
const coverBackgroundImage = computed(() => `url(${ cover.value })`)
const coverBackgroundImage = computed(() => `url(${cover.value})`)
const draggable = computed(() => Boolean(song.value))
const onDragStart = (event: DragEvent) => {

View file

@ -3,7 +3,7 @@
exports[`renders with current song 1`] = `
<div data-v-91ed60f7="" class="playing song-info px-6 py-0 flex items-center content-start w-[84px] md:w-80 gap-5" draggable="true"><span data-v-91ed60f7="" class="album-thumb block h-[55%] md:h-3/4 aspect-square rounded-full bg-cover"></span>
<div data-v-91ed60f7="" class="meta overflow-hidden hidden md:block">
<h3 data-v-91ed60f7="" class="title text-ellipsis overflow-hidden whitespace-nowrap">Fahrstuhl zum Mond</h3><a data-v-91ed60f7="" class="artist text-ellipsis overflow-hidden whitespace-nowrap block text-[0.9rem] !text-k-text-secondary hover:!text-k-accent" href="/#/artist/10">Led Zeppelin</a>
<h3 data-v-91ed60f7="" class="title text-ellipsis overflow-hidden whitespace-nowrap">Fahrstuhl zum Mond</h3><a data-v-91ed60f7="" href="/#/artist/10" class="artist text-ellipsis overflow-hidden whitespace-nowrap block text-[0.9rem] !text-k-text-secondary hover:!text-k-accent">Led Zeppelin</a>
</div>
</div>
`;

View file

@ -2,8 +2,8 @@
<footer
ref="root"
class="flex flex-col relative z-20 bg-k-bg-secondary h-k-footer-height"
@contextmenu.prevent="requestContextMenu"
@mousemove="showControls"
@contextmenu.prevent="requestContextMenu"
>
<AudioPlayer v-show="song" />

View file

@ -9,6 +9,24 @@ import AlbumArtOverlay from '@/components/ui/AlbumArtOverlay.vue'
import MainContent from './MainContent.vue'
new class extends UnitTestCase {
protected test () {
it('has a translucent overlay per album', async () => {
this.mock(albumStore, 'fetchThumbnail').mockResolvedValue('http://test/foo.jpg')
this.renderComponent()
await waitFor(() => screen.getByTestId('album-art-overlay'))
})
it('does not have a translucent over if configured not so', async () => {
preferenceStore.state.show_album_art_overlay = false
this.renderComponent()
await waitFor(() => expect(screen.queryByTestId('album-art-overlay')).toBeNull())
})
}
private renderComponent () {
return this.render(MainContent, {
global: {
@ -32,22 +50,4 @@ new class extends UnitTestCase {
}
})
}
protected test () {
it('has a translucent overlay per album', async () => {
this.mock(albumStore, 'fetchThumbnail').mockResolvedValue('http://test/foo.jpg')
this.renderComponent()
await waitFor(() => screen.getByTestId('album-art-overlay'))
})
it('does not have a translucent over if configured not so', async () => {
preferenceStore.state.show_album_art_overlay = false
this.renderComponent()
await waitFor(() => expect(screen.queryByTestId('album-art-overlay')).toBeNull())
})
}
}

View file

@ -13,7 +13,7 @@
</ExtraDrawerButton>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'
import { eventBus } from '@/utils'
import { useNewVersionNotification } from '@/composables'

View file

@ -9,32 +9,6 @@ import { eventBus } from '@/utils'
import ExtraDrawer from './ExtraDrawer.vue'
new class extends UnitTestCase {
private renderComponent (songRef: Ref<Song | null> = ref(null)): [RenderResult, Mock, Mock] {
const artist = factory<Artist>('artist')
const resolveArtistMock = this.mock(artistStore, 'resolve').mockResolvedValue(artist)
const album = factory<Album>('album')
const resolveAlbumMock = this.mock(albumStore, 'resolve').mockResolvedValue(album)
const rendered = this.render(ExtraDrawer, {
global: {
stubs: {
ProfileAvatar: this.stub(),
LyricsPane: this.stub('lyrics'),
AlbumInfo: this.stub('album-info'),
ArtistInfo: this.stub('artist-info'),
YouTubeVideoList: this.stub('youtube-video-list'),
ExtraPanelTabHeader: this.stub()
},
provide: {
[<symbol>CurrentSongKey]: songRef
}
}
})
return [rendered, resolveArtistMock, resolveAlbumMock]
}
protected test () {
it('renders without a current song', () => expect(this.renderComponent()[0].html()).toMatchSnapshot())
@ -90,4 +64,30 @@ new class extends UnitTestCase {
expect(emitMock).toHaveBeenCalledWith('LOG_OUT')
})
}
private renderComponent (songRef: Ref<Song | null> = ref(null)): [RenderResult, Mock, Mock] {
const artist = factory<Artist>('artist')
const resolveArtistMock = this.mock(artistStore, 'resolve').mockResolvedValue(artist)
const album = factory<Album>('album')
const resolveAlbumMock = this.mock(albumStore, 'resolve').mockResolvedValue(album)
const rendered = this.render(ExtraDrawer, {
global: {
stubs: {
ProfileAvatar: this.stub(),
LyricsPane: this.stub('lyrics'),
AlbumInfo: this.stub('album-info'),
ArtistInfo: this.stub('artist-info'),
YouTubeVideoList: this.stub('youtube-video-list'),
ExtraPanelTabHeader: this.stub()
},
provide: {
[<symbol>CurrentSongKey]: songRef
}
}
})
return [rendered, resolveArtistMock, resolveAlbumMock]
}
}

View file

@ -57,8 +57,8 @@
<div
v-show="activeTab === 'YouTube'"
id="extraPanelYouTube"
data-testid="extra-drawer-youtube"
aria-labelledby="extraTabYouTube"
data-testid="extra-drawer-youtube"
role="tabpanel"
tabindex="0"
>

View file

@ -9,7 +9,7 @@
</button>
</template>
<style scoped lang="postcss">
<style lang="postcss" scoped>
@tailwind utilities;
@layer utilities {

View file

@ -4,7 +4,7 @@
</ExtraDrawerButton>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { faArrowRightFromBracket } from '@fortawesome/free-solid-svg-icons'
import { eventBus } from '@/utils'

View file

@ -22,12 +22,7 @@
</template>
<script lang="ts" setup>
import {
faClockRotateLeft,
faHeart,
faUsers,
faWandMagicSparkles
} from '@fortawesome/free-solid-svg-icons'
import { faClockRotateLeft, faHeart, faUsers, faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'
import { ListMusic } from 'lucide-vue-next'
import { computed, ref, toRefs } from 'vue'
import { eventBus } from '@/utils'

View file

@ -1,11 +1,11 @@
<template>
<SidebarItem
screen="Queue"
href="#/queue"
:class="droppable && 'droppable'"
href="#/queue"
screen="Queue"
@dragleave="onQueueDragLeave"
@dragover.prevent="onQueueDragOver"
@drop="onQueueDrop"
@dragover.prevent="onQueueDragOver"
>
<template #icon>
<Icon :icon="faListOl" fixed-width />
@ -39,7 +39,7 @@ const onQueueDrop = async (event: DragEvent) => {
if (songs.length) {
queueStore.queue(songs)
toastSuccess(`Added ${ pluralize(songs, 'song') } to queue.`)
toastSuccess(`Added ${pluralize(songs, 'song')} to queue.`)
} else {
toastWarning('No applicable songs to queue.')
}

View file

@ -27,13 +27,7 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { eventBus } from '@/utils'
import {
useAuthorization,
useKoelPlus,
useRouter,
useUpload,
useLocalStorage
} from '@/composables'
import { useAuthorization, useKoelPlus, useLocalStorage, useRouter, useUpload } from '@/composables'
import SidebarPlaylistsSection from './SidebarPlaylistsSection.vue'
import SearchForm from '@/components/ui/SearchForm.vue'

View file

@ -5,19 +5,6 @@ import { faHome } from '@fortawesome/free-solid-svg-icons'
import SidebarItem from './SidebarItem.vue'
new class extends UnitTestCase {
private renderComponent () {
return this.render(SidebarItem, {
props: {
icon: faHome,
href: '#',
screen: 'Home'
},
slots: {
default: 'Home'
}
})
}
protected test () {
it('renders', () => expect(this.renderComponent().html()).toMatchSnapshot())
@ -32,4 +19,17 @@ new class extends UnitTestCase {
expect(screen.getByTestId('sidebar-item').classList.contains('current')).toBe(true)
})
}
private renderComponent () {
return this.render(SidebarItem, {
props: {
icon: faHome,
href: '#',
screen: 'Home'
},
slots: {
default: 'Home'
}
})
}
}

View file

@ -5,19 +5,19 @@
</template>
<ul class="menu">
<SidebarItem v-if="isAdmin" screen="Settings" href="#/settings">
<SidebarItem v-if="isAdmin" href="#/settings" screen="Settings">
<template #icon>
<Icon :icon="faTools" fixed-width />
</template>
Settings
</SidebarItem>
<SidebarItem v-if="allowsUpload" screen="Upload" href="#/upload">
<SidebarItem v-if="allowsUpload" href="#/upload" screen="Upload">
<template #icon>
<Icon :icon="faUpload" fixed-width />
</template>
Upload
</SidebarItem>
<SidebarItem v-if="isAdmin" screen="Users" href="#/users">
<SidebarItem v-if="isAdmin" href="#/users" screen="Users">
<template #icon>
<Icon :icon="faUsers" fixed-width />
</template>
@ -26,7 +26,7 @@
</ul>
</SidebarSection>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { faTools, faUpload, faUsers } from '@fortawesome/free-solid-svg-icons'
import { useAuthorization, useUpload } from '@/composables'

View file

@ -8,17 +8,6 @@ import PlaylistSidebarItem from './PlaylistSidebarItem.vue'
import PlaylistFolderSidebarItem from './PlaylistFolderSidebarItem.vue'
new class extends UnitTestCase {
private renderComponent () {
this.render(SidebarPlaylistsSection, {
global: {
stubs: {
PlaylistSidebarItem,
PlaylistFolderSidebarItem
}
}
})
}
protected test () {
it('displays orphan playlists', () => {
playlistStore.state.playlists = [
@ -44,4 +33,15 @@ new class extends UnitTestCase {
;['Foo Folder', 'Bar Folder'].forEach(text => screen.getByText(text))
})
}
private renderComponent () {
this.render(SidebarPlaylistsSection, {
global: {
stubs: {
PlaylistSidebarItem,
PlaylistFolderSidebarItem
}
}
})
}
}

View file

@ -29,7 +29,7 @@ const playlists = toRef(playlistStore.state, 'playlists')
const favorites = toRef(favoriteStore.state, 'songs')
const orphanPlaylists = computed(() => playlists.value.filter(({ folder_id }) => {
if (folder_id === null) return true
if (folder_id === null) return true
// if the playlist's folder is not found, it's an orphan
// this can happen if the playlist belongs to another user (collaborative playlist)

View file

@ -4,13 +4,13 @@
justify-center z-10 text-k-text-secondary bg-k-bg-secondary border-[1.5px] border-white/20 cursor-pointer
hover:text-k-text-primary hover:bg-k-bg-secondary"
>
<input v-model="value" type="checkbox" class="hidden">
<input v-model="value" class="hidden" type="checkbox">
<Icon v-if="value" :icon="faAngleLeft" />
<Icon v-else :icon="faAngleRight" />
</label>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { faAngleLeft, faAngleRight } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'

View file

@ -5,32 +5,32 @@
</template>
<ul class="menu">
<SidebarItem screen="Home" href="#/home">
<SidebarItem href="#/home" screen="Home">
<template #icon>
<Icon :icon="faHome" fixed-width />
</template>
Home
</SidebarItem>
<QueueSidebarItem />
<SidebarItem screen="Songs" href="#/songs">
<SidebarItem href="#/songs" screen="Songs">
<template #icon>
<Icon :icon="faMusic" fixed-width />
</template>
All Songs
</SidebarItem>
<SidebarItem screen="Albums" href="#/albums">
<SidebarItem href="#/albums" screen="Albums">
<template #icon>
<Icon :icon="faCompactDisc" fixed-width />
</template>
Albums
</SidebarItem>
<SidebarItem screen="Artists" href="#/artists">
<SidebarItem href="#/artists" screen="Artists">
<template #icon>
<Icon :icon="faMicrophone" fixed-width />
</template>
Artists
</SidebarItem>
<SidebarItem screen="Genres" href="#/genres">
<SidebarItem href="#/genres" screen="Genres">
<template #icon>
<Icon :icon="faTags" fixed-width />
</template>
@ -43,7 +43,7 @@
</SidebarSection>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { faCompactDisc, faHome, faMicrophone, faMusic, faTags } from '@fortawesome/free-solid-svg-icons'
import { unescape } from 'lodash'
import { ref } from 'vue'

View file

@ -1,5 +1,5 @@
<template>
<SidebarItem screen="YouTube" href="#/youtube">
<SidebarItem href="#/youtube" screen="YouTube">
<template #icon>
<Icon :icon="faYoutube" fixed-width />
</template>

View file

@ -6,16 +6,6 @@ import { screen, waitFor } from '@testing-library/vue'
import AboutKoelModel from './AboutKoelModal.vue'
new class extends UnitTestCase {
private renderComponent () {
return this.render(AboutKoelModel, {
global: {
stubs: {
SponsorList: this.stub('sponsor-list')
}
}
})
}
protected test () {
it('renders', async () => {
commonStore.state.current_version = 'v0.0.0'
@ -44,4 +34,14 @@ new class extends UnitTestCase {
window.IS_DEMO = false
})
}
private renderComponent () {
return this.render(AboutKoelModel, {
global: {
stubs: {
SponsorList: this.stub('sponsor-list')
}
}
})
}
}

View file

@ -8,7 +8,7 @@
>
<main class="p-6">
<div class="mb-4">
<img alt="Koel's logo" src="@/../img/logo.svg" width="128" class="inline-block">
<img alt="Koel's logo" class="inline-block" src="@/../img/logo.svg" width="128">
</div>
<div class="current-version">
@ -57,7 +57,7 @@
</main>
<footer>
<Btn data-testid="close-modal-btn" danger rounded @click.prevent="close">Close</Btn>
<Btn danger data-testid="close-modal-btn" rounded @click.prevent="close">Close</Btn>
</footer>
</div>
</template>

View file

@ -9,7 +9,7 @@
</div>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { orderBy } from 'lodash'
import { onMounted, ref } from 'vue'
import { http } from '@/services'
@ -26,7 +26,7 @@ onMounted(async () => {
})
</script>
<style scoped lang="postcss">
<style lang="postcss" scoped>
li&:last-child {
&::before {
content: ', and '

View file

@ -22,16 +22,6 @@ new class extends UnitTestCase {
})
}
private async renderComponent () {
preferenceStore.initialized.value = true
const rendered = this.render(SupportKoel)
vi.advanceTimersByTime(30 * 60 * 1000)
await this.tick()
return rendered
}
protected test () {
it('shows after a delay', async () => expect((await this.renderComponent()).html()).toMatchSnapshot())
@ -61,4 +51,14 @@ new class extends UnitTestCase {
expect(preferenceStore.state.support_bar_no_bugging).toBe(true)
})
}
private async renderComponent () {
preferenceStore.initialized.value = true
const rendered = this.render(SupportKoel)
vi.advanceTimersByTime(30 * 60 * 1000)
await this.tick()
return rendered
}
}

View file

@ -3,7 +3,7 @@
exports[`renders 1`] = `
<div data-v-6b5b01a9="" class="about text-k-text-secondary text-center max-w-[480px] overflow-hidden relative" data-testid="about-koel" tabindex="0">
<main data-v-6b5b01a9="" class="p-6">
<div data-v-6b5b01a9="" class="mb-4"><img data-v-6b5b01a9="" alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="128" class="inline-block"></div>
<div data-v-6b5b01a9="" class="mb-4"><img data-v-6b5b01a9="" alt="Koel's logo" class="inline-block" src="undefined/resources/assets/img/logo.svg" width="128"></div>
<div data-v-6b5b01a9="" class="current-version"> Koel v0.0.0 <span data-v-6b5b01a9="">Community</span> Edition
<!--v-if-->
</div>
@ -12,6 +12,6 @@ exports[`renders 1`] = `
<!--v-if--><br data-v-6b5b01a9="" data-testid="sponsor-list">
<p data-v-6b5b01a9=""> Loving Koel? Please consider supporting its development via <a data-v-6b5b01a9="" href="https://github.com/users/phanan/sponsorship" rel="noopener" target="_blank">GitHub Sponsors</a> and/or <a data-v-6b5b01a9="" href="https://opencollective.com/koel" rel="noopener" target="_blank">OpenCollective</a>. </p>
</main>
<footer data-v-6b5b01a9=""><button data-v-8943c846="" data-v-6b5b01a9="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer" type="button" data-testid="close-modal-btn" danger="" rounded="">Close</button></footer>
<footer data-v-6b5b01a9=""><button data-v-8943c846="" data-v-6b5b01a9="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer" type="button" danger="" data-testid="close-modal-btn" rounded="">Close</button></footer>
</div>
`;

View file

@ -7,12 +7,6 @@ import { Events } from '@/config'
import CreateNewPlaylistContextMenu from './CreatePlaylistContextMenu.vue'
new class extends UnitTestCase {
private async renderComponent () {
this.render(CreateNewPlaylistContextMenu)
eventBus.emit('CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED', { top: 420, left: 42 })
await this.tick(2)
}
protected test () {
it.each<[string, keyof Events]>([
['playlist-context-menu-create-simple', 'MODAL_SHOW_CREATE_PLAYLIST_FORM'],
@ -25,4 +19,10 @@ new class extends UnitTestCase {
await waitFor(() => expect(emitMock).toHaveBeenCalledWith(eventName))
})
}
private async renderComponent () {
this.render(CreateNewPlaylistContextMenu)
eventBus.emit('CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED', { top: 420, left: 42 })
await this.tick(2)
}
}

View file

@ -9,7 +9,7 @@
</button>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { faCirclePlus } from '@fortawesome/free-solid-svg-icons'
import { eventBus } from '@/utils'

View file

@ -3,7 +3,7 @@
<header>
<h1>
New Playlist
<span v-if="songs.length" data-testid="from-songs" class="text-k-text-secondary">
<span v-if="songs.length" class="text-k-text-secondary" data-testid="from-songs">
from {{ pluralize(songs, 'song') }}
</span>
</h1>

View file

@ -1,6 +1,6 @@
<template>
<span>
<Btn v-if="shouldShowInviteButton" success small @click.prevent="inviteCollaborators">Invite</Btn>
<Btn v-if="shouldShowInviteButton" small success @click.prevent="inviteCollaborators">Invite</Btn>
<span v-if="justCreatedInviteLink" class="text-k-text-secondary text-[0.95rem]">
<Icon :icon="faCheckCircle" class="text-k-success mr-1" />
Link copied to clipboard!
@ -9,7 +9,7 @@
</span>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { faCheckCircle, faCircleNotch } from '@fortawesome/free-solid-svg-icons'
import { computed, ref, toRefs } from 'vue'
import { copyText } from '@/utils'

View file

@ -1,4 +1,4 @@
import { it, expect } from 'vitest'
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { ref } from 'vue'
@ -7,8 +7,8 @@ import Modal from './PlaylistCollaborationModal.vue'
new class extends UnitTestCase {
protected test () {
it ('renders the modal', async () => {
const { html } = this.render(Modal, {
it('renders the modal', async () => {
const { html } = this.render(Modal, {
global: {
provide: {
[<symbol>ModalContextKey]: [ref({ playlist: factory<Playlist>('playlist') })]

View file

@ -28,7 +28,7 @@
</div>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { computed } from 'vue'
import { useAuthorization, useModal } from '@/composables'

View file

@ -5,6 +5,22 @@ import { playlistCollaborationService } from '@/services'
import Component from './PlaylistCollaboratorList.vue'
new class extends UnitTestCase {
protected test () {
it('renders', async () => {
const playlist = factory<Playlist>('playlist', {
is_collaborative: true
})
const fetchMock = this.mock(playlistCollaborationService, 'fetchCollaborators').mockResolvedValue(
factory<PlaylistCollaborator>('playlist-collaborator', 5)
)
const { html } = await this.be().renderComponent(playlist)
expect(fetchMock).toHaveBeenCalledWith(playlist)
expect(html()).toMatchSnapshot()
})
}
private async renderComponent (playlist: Playlist) {
const rendered = this.render(Component, {
props: {
@ -21,20 +37,4 @@ new class extends UnitTestCase {
return rendered
}
protected test () {
it('renders', async () => {
const playlist = factory<Playlist>('playlist', {
is_collaborative: true
})
const fetchMock = this.mock(playlistCollaborationService, 'fetchCollaborators').mockResolvedValue(
factory<PlaylistCollaborator>('playlist-collaborator', 5)
)
const { html } = await this.be().renderComponent(playlist)
expect(fetchMock).toHaveBeenCalledWith(playlist)
expect(html()).toMatchSnapshot()
})
}
}

View file

@ -13,7 +13,7 @@
</ul>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { sortBy } from 'lodash'
import { computed, onMounted, ref, Ref, toRefs } from 'vue'
import { useAuthorization, useDialogBox, useErrorHandler } from '@/composables'

View file

@ -5,22 +5,6 @@ import factory from '@/__tests__/factory'
import Component from './PlaylistCollaboratorListItem.vue'
new class extends UnitTestCase {
private renderComponent (props: {
collaborator: PlaylistCollaborator,
removable: boolean,
manageable: boolean,
role: 'owner' | 'contributor'
}) {
return this.render(Component, {
props,
global: {
stubs: {
UserAvatar: this.stub('UserAvatar')
}
}
})
}
protected test () {
it('does not show a badge when current user is not the collaborator', async () => {
const currentUser = factory<User>('user')
@ -88,4 +72,20 @@ new class extends UnitTestCase {
expect(emitted('remove')).toBeTruthy()
})
}
private renderComponent (props: {
collaborator: PlaylistCollaborator,
removable: boolean,
manageable: boolean,
role: 'owner' | 'contributor'
}) {
return this.render(Component, {
props,
global: {
stubs: {
UserAvatar: this.stub('UserAvatar')
}
}
})
}
}

View file

@ -20,12 +20,12 @@
<span v-else class="contributor">Contributor</span>
</span>
<span v-if="manageable" class="actions flex-[0_0_72px] text-right">
<Btn v-if="removable" small danger @click.prevent="emit('remove')">Remove</Btn>
<Btn v-if="removable" danger small @click.prevent="emit('remove')">Remove</Btn>
</span>
</li>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'
import { toRefs } from 'vue'
@ -46,7 +46,7 @@ const { currentUser } = useAuthorization()
const emit = defineEmits<{ (e: 'remove'): void }>()
</script>
<style scoped lang="postcss">
<style lang="postcss" scoped>
span {
@apply inline-block min-w-0 leading-normal;
}

View file

@ -2,7 +2,7 @@
<div class="inline-block align-middle">
<ul class="align-middle -space-x-2">
<li v-for="user in displayedCollaborators" :key="user.id" class="inline-block align-baseline">
<UserAvatar :user="user" width="24" class="border border-white/30" />
<UserAvatar :user="user" class="border border-white/30" width="24" />
</li>
</ul>
<span v-if="remainderCount" class="ml-2">
@ -11,7 +11,7 @@
</div>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { computed, toRefs } from 'vue'
import UserAvatar from '@/components/user/UserAvatar.vue'

View file

@ -4,23 +4,12 @@ import UnitTestCase from '@/__tests__/UnitTestCase'
import { eventBus } from '@/utils'
import factory from '@/__tests__/factory'
import { screen, waitFor } from '@testing-library/vue'
import { songStore, userStore } from '@/stores'
import { queueStore, songStore, userStore } from '@/stores'
import { playbackService } from '@/services'
import { queueStore } from '@/stores'
import { MessageToasterStub } from '@/__tests__/stubs'
import PlaylistContextMenu from './PlaylistContextMenu.vue'
new class extends UnitTestCase {
private async renderComponent (playlist: Playlist, user: User | null = null) {
userStore.state.current = user || factory<User>('user', {
id: playlist.user_id
})
this.render(PlaylistContextMenu)
eventBus.emit('PLAYLIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, playlist)
await this.tick(2)
}
protected test () {
it('edits a standard playlist', async () => {
const playlist = factory<Playlist>('playlist')
@ -164,4 +153,14 @@ new class extends UnitTestCase {
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_PLAYLIST_COLLABORATION', playlist)
})
}
private async renderComponent (playlist: Playlist, user: User | null = null) {
userStore.state.current = user || factory<User>('user', {
id: playlist.user_id
})
this.render(PlaylistContextMenu)
eventBus.emit('PLAYLIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, playlist)
await this.tick(2)
}
}

View file

@ -16,9 +16,9 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { eventBus } from '@/utils'
import { usePolicies, useContextMenu, useMessageToaster, useKoelPlus, useRouter } from '@/composables'
import { useContextMenu, useKoelPlus, useMessageToaster, usePolicies, useRouter } from '@/composables'
import { playbackService } from '@/services'
import { songStore, queueStore } from '@/stores'
import { queueStore, songStore } from '@/stores'
const { base, ContextMenuBase, open, trigger } = useContextMenu()
const { go } = useRouter()

View file

@ -10,12 +10,6 @@ import { MessageToasterStub } from '@/__tests__/stubs'
import PlaylistFolderContextMenu from './PlaylistFolderContextMenu.vue'
new class extends UnitTestCase {
private async renderComponent (folder: PlaylistFolder) {
this.render(PlaylistFolderContextMenu)
eventBus.emit('PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, folder)
await this.tick(2)
}
protected test () {
it('renames', async () => {
const folder = factory<PlaylistFolder>('playlist-folder')
@ -120,6 +114,12 @@ new class extends UnitTestCase {
})
}
private async renderComponent (folder: PlaylistFolder) {
this.render(PlaylistFolderContextMenu)
eventBus.emit('PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, folder)
await this.tick(2)
}
private createPlayableFolder () {
const folder = factory<PlaylistFolder>('playlist-folder')
this.mock(playlistStore, 'byFolder', factory<Playlist>('playlist', 3, { folder_id: folder.id }))

View file

@ -28,7 +28,7 @@
:is-first-group="index === 0"
@input="onGroupChanged"
/>
<Btn class="btn-add-group" success small title="Add a new group" uppercase @click.prevent="addGroup">
<Btn class="btn-add-group" small success title="Add a new group" uppercase @click.prevent="addGroup">
<Icon :icon="faPlus" />
Group
</Btn>
@ -55,7 +55,8 @@ import { faPlus } from '@fortawesome/free-solid-svg-icons'
import { ref, toRef } from 'vue'
import { playlistFolderStore, playlistStore } from '@/stores'
import {
useDialogBox, useErrorHandler,
useDialogBox,
useErrorHandler,
useKoelPlus,
useMessageToaster,
useModal,

View file

@ -33,7 +33,7 @@
:is-first-group="index === 0"
@input="onGroupChanged"
/>
<Btn class="btn-add-group" success small title="Add a new group" uppercase @click.prevent="addGroup">
<Btn class="btn-add-group" small success title="Add a new group" uppercase @click.prevent="addGroup">
<Icon :icon="faPlus" />
Group
</Btn>
@ -41,7 +41,8 @@
<div v-if="isPlus" class="form-row">
<label class="text-k-text-secondary">
<CheckBox v-model="mutablePlaylist.own_songs_only" /> Only include songs from my own library
<CheckBox v-model="mutablePlaylist.own_songs_only" />
Only include songs from my own library
</label>
</div>
</main>
@ -77,7 +78,7 @@ import SelectBox from '@/components/ui/form/SelectBox.vue'
const { showOverlay, hideOverlay } = useOverlay()
const { toastSuccess } = useMessageToaster()
const { showConfirmDialog } = useDialogBox()
const {isPlus} = useKoelPlus()
const { isPlus } = useKoelPlus()
const playlist = useModal().getFromContext<Playlist>('playlist')
const folders = toRef(playlistFolderStore.state, 'folders')

View file

@ -23,11 +23,11 @@
<div class="text-center absolute w-full left-0 -mt-[2px]">
<Btn
title="Remove this rule"
class="aspect-square scale-75 hover:scale-90 active:scale-[80%]"
rounded
success
small
success
title="Remove this rule"
@click.prevent="addRule"
>
<Icon :icon="faPlus" />

View file

@ -23,7 +23,7 @@
</div>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { faRefresh, faTimes, faUpload } from '@fortawesome/free-solid-svg-icons'
import { computed, ref, toRefs } from 'vue'
import { useFileDialog } from '@vueuse/core'
@ -76,7 +76,7 @@ const onCrop = (result: string) => {
const onCancel = () => (cropperSource.value = null)
</script>
<style scoped lang="postcss">
<style lang="postcss" scoped>
@tailwind utilities;
@layer utilities {

View file

@ -9,7 +9,7 @@
</div>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import LastfmIntegration from '@/components/profile-preferences/LastfmIntegration.vue'
import SpotifyIntegration from '@/components/profile-preferences/SpotifyIntegration.vue'
</script>

View file

@ -28,7 +28,7 @@
Last.fm integration is not enabled.
<span v-if="isAdmin" data-testid="lastfm-admin-instruction">
Check
<a href="https://docs.koel.dev/service-integrations#last-fm" class="text-k-highlight" target="_blank">
<a class="text-k-highlight" href="https://docs.koel.dev/service-integrations#last-fm" target="_blank">
Documentation
</a>
for instructions.

View file

@ -7,10 +7,6 @@ import { MessageToasterStub } from '@/__tests__/stubs'
import ProfileForm from './ProfileForm.vue'
new class extends UnitTestCase {
private renderComponent (user: User) {
return this.be(user).render(ProfileForm)
}
protected test () {
it('updates profile', async () => {
const updateMock = this.mock(authService, 'updateProfile')
@ -37,4 +33,8 @@ new class extends UnitTestCase {
expect(alertMock).toHaveBeenCalledWith('Profile updated.')
})
}
private renderComponent (user: User) {
return this.be(user).render(ProfileForm)
}
}

View file

@ -17,11 +17,11 @@
<TextInput
v-model="profile.current_password"
v-koel-focus
data-testid="currentPassword"
name="current_password"
placeholder="Required to update your profile"
required
type="password"
data-testid="currentPassword"
/>
</FormRow>
@ -73,7 +73,7 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { authService, UpdateCurrentProfileData } from '@/services'
import { useMessageToaster, useAuthorization, useErrorHandler } from '@/composables'
import { useAuthorization, useErrorHandler, useMessageToaster } from '@/composables'
import Btn from '@/components/ui/form/Btn.vue'
import PasswordField from '@/components/ui/form/PasswordField.vue'

View file

@ -1,14 +1,14 @@
<template>
<article class="text-k-text-secondary">
Instead of using a password, you can scan the QR code below to log in to
<a href="https://koel.dev/#mobile" target="_blank" class="text-k-highlight">Koel Player</a>
<a class="text-k-highlight" href="https://koel.dev/#mobile" target="_blank">Koel Player</a>
on your mobile device.<br>
The QR code will refresh every 10 minutes.
<img class="mt-4 rounded-4" :src="qrCodeUrl" alt="QR Code" width="192" height="192">
<img :src="qrCodeUrl" alt="QR Code" class="mt-4 rounded-4" height="192" width="192">
</article>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue'
import { useQRCode } from '@vueuse/integrations/useQRCode'
import { authService } from '@/services'

View file

@ -9,14 +9,6 @@ const theme: Theme = {
}
new class extends UnitTestCase {
private renderComponent () {
return this.render(ThemeCard, {
props: {
theme
}
})
}
protected test () {
it('renders', () => expect(this.renderComponent().html()).toMatchSnapshot())
@ -28,4 +20,12 @@ new class extends UnitTestCase {
expect(emitted().selected[0]).toEqual([theme])
})
}
private renderComponent () {
return this.render(ThemeCard, {
props: {
theme
}
})
}
}

View file

@ -6,8 +6,8 @@
class="theme h-[96px] bg-center bg-cover relative cursor-pointer rounded-lg overflow-hidden border-2 border-solid border-white/10 transition duration-300 hover:border-white/50]"
>
<button
type="button"
class="opacity-0 hover:opacity-100 absolute h-full w-full top-0 left-0 flex items-center justify-center text-lg transition-opacity bg-black/20"
type="button"
@click="$emit('selected', theme)"
>
{{ name }}

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1
exports[`renders 1`] = `<article class="text-k-text-secondary"> Instead of using a password, you can scan the QR code below to log in to <a href="https://koel.dev/#mobile" target="_blank" class="text-k-highlight">Koel Player</a> on your mobile device.<br> The QR code will refresh every 10 minutes. <img class="mt-4 rounded-4" src="data:image/png;base64,my-qr-code" alt="QR Code" width="192" height="192"></article>`;
exports[`renders 1`] = `<article class="text-k-text-secondary"> Instead of using a password, you can scan the QR code below to log in to <a class="text-k-highlight" href="https://koel.dev/#mobile" target="_blank">Koel Player</a> on your mobile device.<br> The QR code will refresh every 10 minutes. <img src="data:image/png;base64,my-qr-code" alt="QR Code" class="mt-4 rounded-4" height="192" width="192"></article>`;

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1
exports[`renders 1`] = `<article data-v-1467c50f="" class="theme h-[96px] bg-center bg-cover relative cursor-pointer rounded-lg overflow-hidden border-2 border-solid border-white/10 transition duration-300 hover:border-white/50]" style="background-color: rgb(255, 0, 0);" title="Set current theme to Sample"><button data-v-1467c50f="" type="button" class="opacity-0 hover:opacity-100 absolute h-full w-full top-0 left-0 flex items-center justify-center text-lg transition-opacity bg-black/20">Sample</button></article>`;
exports[`renders 1`] = `<article data-v-1467c50f="" class="theme h-[96px] bg-center bg-cover relative cursor-pointer rounded-lg overflow-hidden border-2 border-solid border-white/10 transition duration-300 hover:border-white/50]" style="background-color: rgb(255, 0, 0);" title="Set current theme to Sample"><button data-v-1467c50f="" class="opacity-0 hover:opacity-100 absolute h-full w-full top-0 left-0 flex items-center justify-center text-lg transition-opacity bg-black/20" type="button">Sample</button></article>`;

View file

@ -10,20 +10,6 @@ new class extends UnitTestCase {
super.beforeEach(() => this.mock(albumStore, 'paginate'))
}
private async renderComponent () {
albumStore.state.albums = factory<Album>('album', 9)
this.render(AlbumListScreen, {
global: {
stubs: {
AlbumCard: this.stub('album-card')
}
}
})
await this.router.activateRoute({ path: 'albums', screen: 'Albums' })
}
protected test () {
it('renders', async () => {
await this.renderComponent()
@ -55,4 +41,18 @@ new class extends UnitTestCase {
await waitFor(() => expect(screen.getByTestId('album-grid').classList.contains(`as-thumbnails`)).toBe(true))
})
}
private async renderComponent () {
albumStore.state.albums = factory<Album>('album', 9)
this.render(AlbumListScreen, {
global: {
stubs: {
AlbumCard: this.stub('album-card')
}
}
})
await this.router.activateRoute({ path: 'albums', screen: 'Albums' })
}
}

View file

@ -11,44 +11,6 @@ import AlbumScreen from './AlbumScreen.vue'
let album: Album
new class extends UnitTestCase {
private async renderComponent () {
commonStore.state.uses_last_fm = true
album = factory<Album>('album', {
id: 42,
name: 'Led Zeppelin IV',
artist_id: 123,
artist_name: 'Led Zeppelin'
})
const resolveAlbumMock = this.mock(albumStore, 'resolve').mockResolvedValue(album)
const songs = factory<Song>('song', 13)
const fetchSongsMock = this.mock(songStore, 'fetchForAlbum').mockResolvedValue(songs)
await this.router.activateRoute({
path: 'albums/42',
screen: 'Album'
}, { id: '42' })
this.render(AlbumScreen, {
global: {
stubs: {
SongList: this.stub('song-list'),
AlbumCard: this.stub('album-card'),
AlbumInfo: this.stub('album-info')
}
}
})
await waitFor(() => {
expect(resolveAlbumMock).toHaveBeenCalledWith(album.id)
expect(fetchSongsMock).toHaveBeenCalledWith(album.id)
})
await this.tick(2)
}
protected test () {
it('downloads', async () => {
const downloadMock = this.mock(downloadService, 'fromAlbum')
@ -91,4 +53,42 @@ new class extends UnitTestCase {
})
})
}
private async renderComponent () {
commonStore.state.uses_last_fm = true
album = factory<Album>('album', {
id: 42,
name: 'Led Zeppelin IV',
artist_id: 123,
artist_name: 'Led Zeppelin'
})
const resolveAlbumMock = this.mock(albumStore, 'resolve').mockResolvedValue(album)
const songs = factory<Song>('song', 13)
const fetchSongsMock = this.mock(songStore, 'fetchForAlbum').mockResolvedValue(songs)
await this.router.activateRoute({
path: 'albums/42',
screen: 'Album'
}, { id: '42' })
this.render(AlbumScreen, {
global: {
stubs: {
SongList: this.stub('song-list'),
AlbumCard: this.stub('album-card'),
AlbumInfo: this.stub('album-info')
}
}
})
await waitFor(() => {
expect(resolveAlbumMock).toHaveBeenCalledWith(album.id)
expect(fetchSongsMock).toHaveBeenCalledWith(album.id)
})
await this.tick(2)
}
}

View file

@ -44,15 +44,15 @@
<template #header>
<label :class="{ active: activeTab === 'Songs' }">
Songs
<input v-model="activeTab" type="radio" name="tab" value="Songs">
<input v-model="activeTab" name="tab" type="radio" value="Songs">
</label>
<label :class="{ active: activeTab === 'OtherAlbums' }">
Other Albums
<input v-model="activeTab" type="radio" name="tab" value="OtherAlbums">
<input v-model="activeTab" name="tab" type="radio" value="OtherAlbums">
</label>
<label v-if="useLastfm" :class="{ active: activeTab === 'Info' }">
Information
<input v-model="activeTab" type="radio" name="tab" value="Info">
<input v-model="activeTab" name="tab" type="radio" value="Info">
</label>
</template>
@ -70,7 +70,7 @@
<div v-show="activeTab === 'OtherAlbums'" class="albums-pane" data-testid="albums-pane">
<template v-if="otherAlbums">
<AlbumGrid v-if="otherAlbums.length" v-koel-overflow-fade view-mode="list">
<AlbumCard v-for="otherAlbum in otherAlbums" :key="otherAlbum.id" layout="compact" :album="otherAlbum" />
<AlbumCard v-for="otherAlbum in otherAlbums" :key="otherAlbum.id" :album="otherAlbum" layout="compact" />
</AlbumGrid>
<p v-else class="text-k-text-secondary p-6">
No other albums by {{ album.artist_name }} found in the library.
@ -81,7 +81,7 @@
</AlbumGrid>
</div>
<div v-show="activeTab === 'Info'" v-if="useLastfm && album" class="info-pane">
<div v-if="useLastfm && album" v-show="activeTab === 'Info'" class="info-pane">
<AlbumInfo :album="album" mode="full" />
</div>
</ScreenTabs>

View file

@ -16,32 +16,6 @@ new class extends UnitTestCase {
this.be()
}
private async renderComponent () {
const fetchMock = this.mock(songStore, 'paginate').mockResolvedValue(2)
this.router.$currentRoute.value = {
screen: 'Songs',
path: '/songs'
}
const rendered = this.render(AllSongsScreen, {
global: {
stubs: {
SongList: this.stub('song-list')
}
}
})
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith({
sort: 'title',
order: 'asc',
page: 1,
own_songs_only: false
}))
return [rendered, fetchMock] as const
}
protected test () {
it('renders', async () => {
const [{ html }] = await this.renderComponent()
@ -78,4 +52,30 @@ new class extends UnitTestCase {
}))
})
}
private async renderComponent () {
const fetchMock = this.mock(songStore, 'paginate').mockResolvedValue(2)
this.router.$currentRoute.value = {
screen: 'Songs',
path: '/songs'
}
const rendered = this.render(AllSongsScreen, {
global: {
stubs: {
SongList: this.stub('song-list')
}
}
})
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith({
sort: 'title',
order: 'asc',
page: 1,
own_songs_only: false
}))
return [rendered, fetchMock] as const
}
}

View file

@ -67,11 +67,12 @@ import { pluralize, secondsToHumanReadable } from '@/utils'
import { commonStore, queueStore, songStore } from '@/stores'
import { playbackService } from '@/services'
import {
useErrorHandler,
useKoelPlus,
useLocalStorage,
useRouter,
useSongList,
useSongListControls,
useLocalStorage, useErrorHandler
useSongListControls
} from '@/composables'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'

View file

@ -10,21 +10,6 @@ new class extends UnitTestCase {
super.beforeEach(() => this.mock(artistStore, 'paginate'))
}
private async renderComponent () {
artistStore.state.artists = factory<Artist>('artist', 9)
const rendered = this.render(ArtistListScreen, {
global: {
stubs: {
ArtistCard: this.stub('artist-card')
}
}
})
await this.router.activateRoute({ path: 'artists', screen: 'Artists' })
return rendered
}
protected test () {
it('renders', async () => {
await this.renderComponent()
@ -56,4 +41,19 @@ new class extends UnitTestCase {
await waitFor(() => expect(screen.getByTestId('artist-list').classList.contains(`as-thumbnails`)).toBe(true))
})
}
private async renderComponent () {
artistStore.state.artists = factory<Artist>('artist', 9)
const rendered = this.render(ArtistListScreen, {
global: {
stubs: {
ArtistCard: this.stub('artist-card')
}
}
})
await this.router.activateRoute({ path: 'artists', screen: 'Artists' })
return rendered
}
}

View file

@ -43,15 +43,15 @@
<template #header>
<label :class="{ active: activeTab === 'Songs' }">
Songs
<input v-model="activeTab" type="radio" name="tab" value="Songs">
<input v-model="activeTab" name="tab" type="radio" value="Songs">
</label>
<label :class="{ active: activeTab === 'Albums' }">
Albums
<input v-model="activeTab" type="radio" name="tab" value="Albums">
<input v-model="activeTab" name="tab" type="radio" value="Albums">
</label>
<label v-if="useLastfm" :class="{ active: activeTab === 'Info' }">
Information
<input v-model="activeTab" type="radio" name="tab" value="Info">
<input v-model="activeTab" name="tab" type="radio" value="Info">
</label>
</template>
@ -77,7 +77,7 @@
</AlbumOrArtistGrid>
</div>
<div v-show="activeTab === 'Info'" v-if="useLastfm && artist" class="info-pane">
<div v-if="useLastfm && artist" v-show="activeTab === 'Info'" class="info-pane">
<ArtistInfo :artist="artist" mode="full" />
</div>
</ScreenTabs>
@ -89,13 +89,7 @@ import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'
import { eventBus, pluralize } from '@/utils'
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
import { downloadService } from '@/services'
import {
useErrorHandler,
useRouter,
useSongList,
useSongListControls,
useThirdPartyServices
} from '@/composables'
import { useErrorHandler, useRouter, useSongList, useSongListControls, useThirdPartyServices } from '@/composables'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ArtistThumbnail from '@/components/ui/ArtistAlbumThumbnail.vue'

View file

@ -6,15 +6,6 @@ import { favoriteStore } from '@/stores'
import FavoritesScreen from './FavoritesScreen.vue'
new class extends UnitTestCase {
private async renderComponent () {
const fetchMock = this.mock(favoriteStore, 'fetch')
this.render(FavoritesScreen)
await this.router.activateRoute({ path: 'favorites', screen: 'Favorites' })
await waitFor(() => expect(fetchMock).toHaveBeenCalled())
}
protected test () {
it('renders a list of favorites', async () => {
favoriteStore.state.songs = factory<Song>('song', 13)
@ -34,4 +25,13 @@ new class extends UnitTestCase {
expect(screen.queryByTestId('song-list')).toBeNull()
})
}
private async renderComponent () {
const fetchMock = this.mock(favoriteStore, 'fetch')
this.render(FavoritesScreen)
await this.router.activateRoute({ path: 'favorites', screen: 'Favorites' })
await waitFor(() => expect(fetchMock).toHaveBeenCalled())
}
}

View file

@ -22,10 +22,10 @@
class="rounded-[0.5em] inline-block m-1.5 align-middle overflow-hidden"
>
<a
class="bg-white/15 inline-flex items-center justify-center !text-k-text-secondary
transition-colors duration-200 ease-in-out hover:!text-k-text-primary hover:bg-k-highlight"
:href="`/#/genres/${encodeURIComponent(genre.name)}`"
:title="`${genre.name}: ${pluralize(genre.song_count, 'song')}`"
class="bg-white/15 inline-flex items-center justify-center !text-k-text-secondary
transition-colors duration-200 ease-in-out hover:!text-k-text-primary hover:bg-k-highlight"
>
<span class="name bg-white/5 px-[0.5em] py-[0.2em] leading-normal">{{ genre.name }}</span>
<span class="count items-center px-[0.5em] py-[0.2em]">

View file

@ -7,6 +7,41 @@ import { playbackService } from '@/services'
import GenreScreen from './GenreScreen.vue'
new class extends UnitTestCase {
protected test () {
it('renders the song list', async () => {
await this.renderComponent()
expect(screen.getByTestId('song-list')).toBeTruthy()
})
it('shuffles all songs without fetching if genre has <= 500 songs', async () => {
const genre = factory<Genre>('genre', { song_count: 10 })
const songs = factory<Song>('song', 10)
const playbackMock = this.mock(playbackService, 'queueAndPlay')
await this.renderComponent(genre, songs)
await this.user.click(screen.getByTitle('Shuffle all. Press Alt/⌥ to change mode.'))
expect(playbackMock).toHaveBeenCalledWith(songs, true)
})
it('fetches and shuffles all songs if genre has > 500 songs', async () => {
const genre = factory<Genre>('genre', { song_count: 501 })
const songs = factory<Song>('song', 10) // we don't really need to generate 501 songs
const playbackMock = this.mock(playbackService, 'queueAndPlay')
const fetchMock = this.mock(songStore, 'fetchRandomForGenre').mockResolvedValue(songs)
await this.renderComponent(genre, songs)
await this.user.click(screen.getByTitle('Shuffle all. Press Alt/⌥ to change mode.'))
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(genre, 500)
expect(playbackMock).toHaveBeenCalledWith(songs)
})
})
}
private async renderComponent (genre?: Genre, songs?: Song[]) {
genre = genre || factory<Genre>('genre')
@ -42,39 +77,4 @@ new class extends UnitTestCase {
return rendered
}
protected test () {
it('renders the song list', async () => {
await this.renderComponent()
expect(screen.getByTestId('song-list')).toBeTruthy()
})
it('shuffles all songs without fetching if genre has <= 500 songs', async () => {
const genre = factory<Genre>('genre', { song_count: 10 })
const songs = factory<Song>('song', 10)
const playbackMock = this.mock(playbackService, 'queueAndPlay')
await this.renderComponent(genre, songs)
await this.user.click(screen.getByTitle('Shuffle all. Press Alt/⌥ to change mode.'))
expect(playbackMock).toHaveBeenCalledWith(songs, true)
})
it('fetches and shuffles all songs if genre has > 500 songs', async () => {
const genre = factory<Genre>('genre', { song_count: 501 })
const songs = factory<Song>('song', 10) // we don't really need to generate 501 songs
const playbackMock = this.mock(playbackService, 'queueAndPlay')
const fetchMock = this.mock(songStore, 'fetchRandomForGenre').mockResolvedValue(songs)
await this.renderComponent(genre, songs)
await this.user.click(screen.getByTitle('Shuffle all. Press Alt/⌥ to change mode.'))
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(genre, 500)
expect(playbackMock).toHaveBeenCalledWith(songs)
})
})
}
}

View file

@ -7,11 +7,6 @@ import { screen } from '@testing-library/vue'
import HomeScreen from './HomeScreen.vue'
new class extends UnitTestCase {
private async renderComponent () {
this.render(HomeScreen)
await this.router.activateRoute({ path: 'home', screen: 'Home' })
}
protected test () {
it('renders an empty state if no songs found', async () => {
commonStore.state.song_length = 0
@ -54,4 +49,9 @@ new class extends UnitTestCase {
expect(refreshMock).toHaveBeenCalled()
})
}
private async renderComponent () {
this.render(HomeScreen)
await this.router.activateRoute({ path: 'home', screen: 'Home' })
}
}

View file

@ -16,17 +16,17 @@
<div v-else class="space-y-12">
<div class="grid grid-cols-1 md:grid-cols-2 w-full gap-8 md:gap-4">
<MostPlayedSongs data-testid="most-played-songs" :loading="loading" />
<RecentlyPlayedSongs data-testid="recently-played-songs" :loading="loading" />
<MostPlayedSongs :loading="loading" data-testid="most-played-songs" />
<RecentlyPlayedSongs :loading="loading" data-testid="recently-played-songs" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 w-full gap-8 md:gap-4">
<RecentlyAddedAlbums data-testid="recently-added-albums" :loading="loading" />
<RecentlyAddedSongs data-testid="recently-added-songs" :loading="loading" />
<RecentlyAddedAlbums :loading="loading" data-testid="recently-added-albums" />
<RecentlyAddedSongs :loading="loading" data-testid="recently-added-songs" />
</div>
<MostPlayedArtists data-testid="most-played-artists" :loading="loading" />
<MostPlayedAlbums data-testid="most-played-albums" :loading="loading" />
<MostPlayedArtists :loading="loading" data-testid="most-played-artists" />
<MostPlayedAlbums :loading="loading" data-testid="most-played-albums" />
<BtnScrollToTop />
</div>

View file

@ -10,25 +10,6 @@ import PlaylistScreen from './PlaylistScreen.vue'
let playlist: Playlist
new class extends UnitTestCase {
private async renderComponent (songs: Song[]) {
playlist = playlist || factory<Playlist>('playlist')
playlistStore.init([playlist])
playlist.songs = songs
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs)
const rendered = this.render(PlaylistScreen)
await this.router.activateRoute({
path: `playlists/${playlist.id}`,
screen: 'Playlist'
}, { id: playlist.id })
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(playlist, false))
return { rendered, fetchMock }
}
protected test () {
it('renders the playlist', async () => {
await this.renderComponent(factory<Song>('song', 10))
@ -75,4 +56,23 @@ new class extends UnitTestCase {
expect(fetchMock).toHaveBeenCalledWith(playlist, true)
})
}
private async renderComponent (songs: Song[]) {
playlist = playlist || factory<Playlist>('playlist')
playlistStore.init([playlist])
playlist.songs = songs
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs)
const rendered = this.render(PlaylistScreen)
await this.router.activateRoute({
path: `playlists/${playlist.id}`,
screen: 'Playlist'
}, { id: playlist.id })
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(playlist, false))
return { rendered, fetchMock }
}
}

View file

@ -1,7 +1,7 @@
<template>
<ScreenBase v-if="playlist">
<template #header>
<ScreenHeader :layout="songs.length === 0 ? 'collapsed' : headerLayout" :disabled="loading">
<ScreenHeader :disabled="loading" :layout="songs.length === 0 ? 'collapsed' : headerLayout">
{{ playlist.name }}
<ControlsToggle v-if="songs.length" v-model="showingControls" />
@ -29,11 +29,11 @@
<SongListControls
v-if="!isPhone || showingControls"
:config="controlsConfig"
@delete-playlist="destroy"
@filter="applyFilter"
@refresh="fetchDetails(true)"
@delete-playlist="destroy"
@play-all="playAll"
@play-selected="playSelected"
@refresh="fetchDetails(true)"
/>
</template>
</ScreenHeader>
@ -44,11 +44,11 @@
v-if="!loading && songs.length"
ref="songList"
class="-m-6"
@reorder="onReorder"
@sort="sort"
@press:delete="removeSelected"
@press:enter="onPressEnter"
@scroll-breakpoint="onScrollBreakpoint"
@reorder="onReorder"
/>
<ScreenEmptyState v-if="!songs.length && !loading">
@ -78,12 +78,12 @@ import { eventBus, pluralize } from '@/utils'
import { commonStore, playlistStore, songStore } from '@/stores'
import { downloadService, playlistCollaborationService } from '@/services'
import {
useAuthorization,
useErrorHandler,
usePlaylistManagement,
useRouter,
useSongList,
useAuthorization,
useSongListControls,
useErrorHandler
useSongListControls
} from '@/composables'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'

View file

@ -7,18 +7,6 @@ import { playbackService } from '@/services'
import QueueScreen from './QueueScreen.vue'
new class extends UnitTestCase {
private renderComponent (songs: Song[]) {
queueStore.state.songs = songs
this.render(QueueScreen, {
global: {
stubs: {
SongList: this.stub('song-list')
}
}
})
}
protected test () {
it('renders the queue', () => {
this.renderComponent(factory<Song>('song', 3))
@ -57,4 +45,16 @@ new class extends UnitTestCase {
await waitFor(() => expect(playMock).toHaveBeenCalledWith(songs, true))
})
}
private renderComponent (songs: Song[]) {
queueStore.state.songs = songs
this.render(QueueScreen, {
global: {
stubs: {
SongList: this.stub('song-list')
}
}
})
}
}

View file

@ -6,6 +6,22 @@ import { screen, waitFor } from '@testing-library/vue'
import RecentlyPlayedScreen from './RecentlyPlayedScreen.vue'
new class extends UnitTestCase {
protected test () {
it('displays the songs', async () => {
await this.renderComponent(factory<Song>('song', 3))
screen.getByTestId('song-list')
expect(screen.queryByTestId('screen-empty-state')).toBeNull()
})
it('displays the empty state', async () => {
await this.renderComponent([])
expect(screen.queryByTestId('song-list')).toBeNull()
screen.getByTestId('screen-empty-state')
})
}
private async renderComponent (songs: Song[]) {
recentlyPlayedStore.state.songs = songs
const fetchMock = this.mock(recentlyPlayedStore, 'fetch')
@ -22,20 +38,4 @@ new class extends UnitTestCase {
await waitFor(() => expect(fetchMock).toHaveBeenCalled())
}
protected test () {
it('displays the songs', async () => {
await this.renderComponent(factory<Song>('song', 3))
screen.getByTestId('song-list')
expect(screen.queryByTestId('screen-empty-state')).toBeNull()
})
it('displays the empty state', async () => {
await this.renderComponent([])
expect(screen.queryByTestId('song-list')).toBeNull()
screen.getByTestId('screen-empty-state')
})
}
}

View file

@ -44,10 +44,10 @@
or click here to select songs
<input
:accept="acceptAttribute"
class="absolute opacity-0 w-full h-full z-[2] cursor-pointer left-0 top-0"
multiple
name="file[]"
type="file"
class="absolute opacity-0 w-full h-full z-[2] cursor-pointer left-0 top-0"
@change="onFileInputChange"
>
</a>

Some files were not shown because too many files have changed in this diff Show more