feat: add tests and fixes for SongEditForm

This commit is contained in:
Phan An 2022-07-20 23:20:43 +02:00
parent f67c9a23de
commit 2ffb39c1b8
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
11 changed files with 253 additions and 73 deletions

View file

@ -10,7 +10,6 @@ use App\Http\Middleware\TrimStrings;
use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Routing\Middleware\SubstituteBindings;
@ -25,7 +24,6 @@ class Kernel extends HttpKernel
CheckForMaintenanceMode::class,
ValidatePostSize::class,
TrimStrings::class,
ConvertEmptyStringsToNull::class,
ForceHttps::class,
];

View file

@ -74,8 +74,8 @@ class SongService
$maybeSetAlbum();
}
$song->title = $data->title ?: $song->title;
$song->lyrics = $data->lyrics ?: $song->lyrics;
$song->title = $data->title ?? $song->title; // Empty string still has effects
$song->lyrics = $data->lyrics ?? $song->lyrics; // Empty string still has effects
$song->track = $data->track ?: $song->track;
$song->disc = $data->disc ?: $song->disc;

View file

@ -59,7 +59,6 @@
"crypto-random-string": "^1.0.0",
"css-loader": "^0.28.7",
"cypress": "^9.5.4",
"deepmerge": "^4.2.2",
"eslint": "^8.14.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-node": "^11.1.0",

View file

@ -1,12 +1,24 @@
import deepmerge from 'deepmerge'
import isMobile from 'ismobilejs'
import { isObject, mergeWith } from 'lodash'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { cleanup, render, RenderOptions } from '@testing-library/vue'
import { afterEach, beforeEach, vi } from 'vitest'
import { clickaway, droppable, focus } from '@/directives'
import { defineComponent, nextTick } from 'vue'
import { commonStore, userStore } from '@/stores'
import factory from '@/__tests__/factory'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
// A deep-merge function that
// - supports symbols as keys (_.merge doesn't)
// - supports Vue's Ref type without losing reactivity (deepmerge doesn't)
// Credit: https://stackoverflow.com/a/60598589/794641
const deepMerge = (first: object, second: object) => {
return mergeWith(first, second, (a, b) => {
if (!isObject(b)) return b
return Array.isArray(a) ? [...a, ...b] : { ...a, ...b }
})
}
export default abstract class UnitTestCase {
private backupMethods = new Map()
@ -64,7 +76,7 @@ export default abstract class UnitTestCase {
}
protected render (component: any, options: RenderOptions = {}) {
return render(component, deepmerge({
return render(component, deepMerge({
global: {
directives: {
'koel-clickaway': clickaway,

View file

@ -0,0 +1,109 @@
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { alerts, arrayify } from '@/utils'
import SongEditForm from './SongEditForm.vue'
import { EditSongFormInitialTabKey, SongsKey } from '@/symbols'
import { ref } from 'vue'
import { fireEvent } from '@testing-library/vue'
import { songStore } from '@/stores'
let songs: Song[]
new class extends UnitTestCase {
private async renderComponent (_songs: Song | Song[], initialTab: EditSongFormTabName = 'details') {
songs = arrayify(_songs)
const rendered = this.render(SongEditForm, {
global: {
provide: {
[SongsKey]: [ref(songs)],
[EditSongFormInitialTabKey]: [ref(initialTab)]
}
}
})
await this.tick()
return rendered
}
protected test () {
it('edits a single song', async () => {
const updateMock = this.mock(songStore, 'update')
const alertMock = this.mock(alerts, 'success')
const { html, getByTestId, getByRole } = await this.renderComponent(factory<Song>('song', {
title: 'Rocket to Heaven',
artist_name: 'Led Zeppelin',
album_name: 'IV',
album_cover: 'https://example.co/album.jpg'
}))
expect(html()).toMatchSnapshot()
await fireEvent.update(getByTestId('title-input'), 'Highway to Hell')
await fireEvent.update(getByTestId('artist-input'), 'AC/DC')
await fireEvent.update(getByTestId('albumArtist-input'), 'AC/DC')
await fireEvent.update(getByTestId('album-input'), 'Back in Black')
await fireEvent.update(getByTestId('disc-input'), '1')
await fireEvent.update(getByTestId('track-input'), '10')
await fireEvent.update(getByTestId('lyrics-input'), 'I\'m gonna make him an offer he can\'t refuse')
await fireEvent.click(getByRole('button', { name: 'Update' }))
expect(updateMock).toHaveBeenCalledWith(songs, {
title: 'Highway to Hell',
album_name: 'Back in Black',
artist_name: 'AC/DC',
album_artist_name: 'AC/DC',
lyrics: 'I\'m gonna make him an offer he can\'t refuse',
track: '10',
disc: '1'
})
expect(alertMock).toHaveBeenCalledWith('Updated 1 song.')
})
it('edits multiple songs', async () => {
const updateMock = this.mock(songStore, 'update')
const alertMock = this.mock(alerts, 'success')
const { html, getByTestId, getByRole, queryByTestId } = await this.renderComponent(factory<Song[]>('song', 3))
expect(html()).toMatchSnapshot()
expect(queryByTestId('title-input')).toBeNull()
expect(queryByTestId('lyrics-input')).toBeNull()
await fireEvent.update(getByTestId('artist-input'), 'AC/DC')
await fireEvent.update(getByTestId('albumArtist-input'), 'AC/DC')
await fireEvent.update(getByTestId('album-input'), 'Back in Black')
await fireEvent.update(getByTestId('disc-input'), '1')
await fireEvent.update(getByTestId('track-input'), '10')
await fireEvent.click(getByRole('button', { name: 'Update' }))
expect(updateMock).toHaveBeenCalledWith(songs, {
album_name: 'Back in Black',
artist_name: 'AC/DC',
album_artist_name: 'AC/DC',
track: '10',
disc: '1'
})
expect(alertMock).toHaveBeenCalledWith('Updated 3 songs.')
})
it('displays artist name if all songs have the same artist', async () => {
const { getByTestId } = await this.renderComponent(factory<Song[]>('song', {
artist_id: 1000,
artist_name: 'Led Zeppelin',
album_id: 1001,
album_name: 'IV'
}, 4))
expect(getByTestId('displayed-artist-name').textContent).toBe('Led Zeppelin')
expect(getByTestId('displayed-album-name').textContent).toBe('IV')
})
}
}

View file

@ -6,8 +6,18 @@
<span class="cover" :style="{ backgroundImage: `url(${coverUrl})` }"/>
<div class="meta">
<h1 :class="{ mixed: !editingOnlyOneSong }">{{ displayedTitle }}</h1>
<h2 :class="{ mixed: !allSongsAreFromSameArtist && !formData.artist_name }">{{ displayedArtistName }}</h2>
<h2 :class="{ mixed: !allSongsAreInSameAlbum && !formData.album_name }">{{ displayedAlbumName }}</h2>
<h2
data-testid="displayed-artist-name"
:class="{ mixed: !allSongsAreFromSameArtist && !formData.artist_name }"
>
{{ displayedArtistName }}
</h2>
<h2
data-testid="displayed-album-name"
:class="{ mixed: !allSongsAreInSameAlbum && !formData.album_name }"
>
{{ displayedAlbumName }}
</h2>
</div>
</header>
@ -47,7 +57,14 @@
>
<div v-if="editingOnlyOneSong" class="form-row">
<label>Title</label>
<input v-model="formData.title" v-koel-focus name="title" title="Title" type="text">
<input
v-model="formData.title"
v-koel-focus
data-testid="title-input"
name="title"
title="Title"
type="text"
>
</div>
<div class="form-row">
@ -55,6 +72,7 @@
<input
v-model="formData.artist_name"
:placeholder="inputPlaceholder"
data-testid="artist-input"
name="artist"
type="text"
>
@ -65,6 +83,7 @@
<input
v-model="formData.album_name"
:placeholder="inputPlaceholder"
data-testid="album-input"
name="album"
type="text"
>
@ -74,8 +93,9 @@
<label>Album Artist</label>
<input
v-model="formData.album_artist_name"
name="album_artist"
:placeholder="inputPlaceholder"
data-testid="albumArtist-input"
name="album_artist"
type="text"
>
</div>
@ -86,22 +106,24 @@
<label>Track</label>
<input
v-model="formData.track"
:placeholder="inputPlaceholder"
data-testid="track-input"
name="track"
pattern="\d*"
title="Empty or a number"
type="text"
:placeholder="inputPlaceholder"
>
</div>
<div>
<label>Disc</label>
<input
v-model="formData.disc"
:placeholder="inputPlaceholder"
data-testid="disc-input"
name="disc"
pattern="\d*"
title="Empty or a number"
type="text"
:placeholder="inputPlaceholder"
>
</div>
</div>
@ -117,7 +139,13 @@
tabindex="0"
>
<div class="form-row">
<textarea v-model="formData.lyrics" v-koel-focus name="lyrics" title="Lyrics"></textarea>
<textarea
v-model="formData.lyrics"
v-koel-focus
data-testid="lyrics-input"
name="lyrics"
title="Lyrics"
/>
</div>
</div>
</div>
@ -141,7 +169,15 @@ import { EditSongFormInitialTabKey, SongsKey } from '@/symbols'
import Btn from '@/components/ui/Btn.vue'
import SoundBar from '@/components/ui/SoundBar.vue'
type EditFormData = Pick<Song, 'title' | 'album_name' | 'artist_name' | 'album_artist_name' | 'lyrics' | 'track' | 'disc'>
type EditFormData = {
title?: string
artist_name?: string
album_name?: string
album_artist_name?: string
track?: string | null
disc?: string | null
lyrics?: string
}
const [initialTab] = requireInjection(EditSongFormInitialTabKey)
const [songs] = requireInjection(SongsKey)
@ -204,9 +240,12 @@ const open = async () => {
formData.album_name = allSongsShareSameValue('album_name') ? firstSong.album_name : ''
formData.artist_name = allSongsShareSameValue('artist_name') ? firstSong.artist_name : ''
// If the album artist is the same as the artist, we set the form value as empty to not confuse the user
// If the album artist(s) is the same as the artist(s), we set the form value as empty to not confuse the user
// and make it less error-prone.
if (editingOnlyOneSong.value && firstSong.album_artist_id === firstSong.artist_id) {
if (
allSongsShareSameValue('artist_name') && allSongsShareSameValue('album_artist_name')
&& firstSong.album_artist_id === firstSong.artist_id
) {
formData.album_artist_name = ''
} else {
formData.album_artist_name = allSongsShareSameValue('album_artist_name') ? firstSong.album_artist_name : ''
@ -216,6 +255,11 @@ const open = async () => {
formData.track = allSongsShareSameValue('track') ? firstSong.track : null
formData.disc = allSongsShareSameValue('disc') ? firstSong.disc : null
if (!editingOnlyOneSong.value) {
delete formData.title
delete formData.lyrics
}
Object.assign(initialFormData, formData)
}

View file

@ -1,49 +0,0 @@
import lodash from 'lodash'
import factory from '@/__tests__/factory'
import { expect, it } from 'vitest'
import { fireEvent } from '@testing-library/vue'
import UnitTestCase from '@/__tests__/UnitTestCase'
import SongList from './SongList.vue'
let songs: Song[]
new class extends UnitTestCase {
private renderComponent (type: SongListType = 'all-songs') {
songs = factory<Song>('song', 3)
return this.render(SongList, {
props: {
items: songs,
type
}
})
}
protected test () {
it.each<[string, SongListSortField[]]>([
['header-track-number', ['track', 'disc']],
['header-title', ['title']],
['header-artist', ['artist_name', 'album_name', 'track', 'disc']],
['header-album', ['album_name', 'track', 'disc']],
['header-length', ['length']]
])('sorts when "%s" header is clicked', async (testId: string, sortFields: SongListSortField[]) => {
const mock = this.mock(lodash, 'orderBy', [])
const { getByTestId } = this.renderComponent()
await fireEvent.click(getByTestId(testId))
expect(mock).toHaveBeenCalledWith(expect.anything(), sortFields, 'asc')
})
it.each<[string, string]>([
['Enter', 'press:enter'],
['Delete', 'press:delete']
])('emits when %s key is pressed', async (key: string, eventName: string) => {
const { emitted, getByTestId } = this.renderComponent()
await fireEvent.keyDown(getByTestId('song-list'), { key })
expect(emitted()[eventName]).toBeTruthy()
})
}
}

View file

@ -0,0 +1,71 @@
// Vitest Snapshot v1
exports[`edits a single song 1`] = `
<div class="edit-song" data-testid="edit-song-form" tabindex="0" data-v-5f0f6176="">
<form data-v-5f0f6176="">
<header data-v-5f0f6176=""><span class="cover" style="background-image: url(https://example.co/album.jpg);" data-v-5f0f6176=""></span>
<div class="meta" data-v-5f0f6176="">
<h1 class="" data-v-5f0f6176="">Rocket to Heaven</h1>
<h2 data-testid="displayed-artist-name" class="" data-v-5f0f6176="">Led Zeppelin</h2>
<h2 data-testid="displayed-album-name" class="" data-v-5f0f6176="">IV</h2>
</div>
</header>
<div class="tabs" data-v-5f0f6176="">
<div class="clear" role="tablist" data-v-5f0f6176=""><button id="editSongTabDetails" aria-selected="true" aria-controls="editSongPanelDetails" role="tab" type="button" data-v-5f0f6176=""> Details </button><button id="editSongTabLyrics" aria-selected="false" aria-controls="editSongPanelLyrics" data-testid="edit-song-lyrics-tab" role="tab" type="button" data-v-5f0f6176=""> Lyrics </button></div>
<div class="panes" data-v-5f0f6176="">
<div id="editSongPanelDetails" aria-labelledby="editSongTabDetails" role="tabpanel" tabindex="0" data-v-5f0f6176="">
<div class="form-row" data-v-5f0f6176=""><label data-v-5f0f6176="">Title</label><input data-testid="title-input" name="title" title="Title" type="text" data-v-5f0f6176=""></div>
<div class="form-row" data-v-5f0f6176=""><label data-v-5f0f6176="">Artist</label><input placeholder="" data-testid="artist-input" name="artist" type="text" data-v-5f0f6176=""></div>
<div class="form-row" data-v-5f0f6176=""><label data-v-5f0f6176="">Album</label><input placeholder="" data-testid="album-input" name="album" type="text" data-v-5f0f6176=""></div>
<div class="form-row" data-v-5f0f6176=""><label data-v-5f0f6176="">Album Artist</label><input placeholder="" data-testid="albumArtist-input" name="album_artist" type="text" data-v-5f0f6176=""></div>
<div class="form-row" data-v-5f0f6176="">
<div class="cols" data-v-5f0f6176="">
<div data-v-5f0f6176=""><label data-v-5f0f6176="">Track</label><input placeholder="" data-testid="track-input" name="track" pattern="\\d*" title="Empty or a number" type="text" data-v-5f0f6176=""></div>
<div data-v-5f0f6176=""><label data-v-5f0f6176="">Disc</label><input placeholder="" data-testid="disc-input" name="disc" pattern="\\d*" title="Empty or a number" type="text" data-v-5f0f6176=""></div>
</div>
</div>
</div>
<div id="editSongPanelLyrics" aria-labelledby="editSongTabLyrics" role="tabpanel" tabindex="0" data-v-5f0f6176="" style="display: none;">
<div class="form-row" data-v-5f0f6176=""><textarea data-testid="lyrics-input" name="lyrics" title="Lyrics" data-v-5f0f6176=""></textarea></div>
</div>
</div>
</div>
<footer data-v-5f0f6176=""><button type="submit" data-v-27deb898="" data-v-5f0f6176="">Update</button><button type="button" class="btn-cancel" white="" data-v-27deb898="" data-v-5f0f6176="">Cancel</button></footer>
</form>
</div>
`;
exports[`edits multiple songs 1`] = `
<div class="edit-song" data-testid="edit-song-form" tabindex="0" data-v-5f0f6176="">
<form data-v-5f0f6176="">
<header data-v-5f0f6176=""><span class="cover" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-v-5f0f6176=""></span>
<div class="meta" data-v-5f0f6176="">
<h1 class="mixed" data-v-5f0f6176="">3 songs selected</h1>
<h2 data-testid="displayed-artist-name" class="mixed" data-v-5f0f6176="">Mixed Artists</h2>
<h2 data-testid="displayed-album-name" class="mixed" data-v-5f0f6176="">Mixed Albums</h2>
</div>
</header>
<div class="tabs" data-v-5f0f6176="">
<div class="clear" role="tablist" data-v-5f0f6176=""><button id="editSongTabDetails" aria-selected="true" aria-controls="editSongPanelDetails" role="tab" type="button" data-v-5f0f6176=""> Details </button>
<!--v-if-->
</div>
<div class="panes" data-v-5f0f6176="">
<div id="editSongPanelDetails" aria-labelledby="editSongTabDetails" role="tabpanel" tabindex="0" data-v-5f0f6176="">
<!--v-if-->
<div class="form-row" data-v-5f0f6176=""><label data-v-5f0f6176="">Artist</label><input placeholder="Leave unchanged" data-testid="artist-input" name="artist" type="text" data-v-5f0f6176=""></div>
<div class="form-row" data-v-5f0f6176=""><label data-v-5f0f6176="">Album</label><input placeholder="Leave unchanged" data-testid="album-input" name="album" type="text" data-v-5f0f6176=""></div>
<div class="form-row" data-v-5f0f6176=""><label data-v-5f0f6176="">Album Artist</label><input placeholder="Leave unchanged" data-testid="albumArtist-input" name="album_artist" type="text" data-v-5f0f6176=""></div>
<div class="form-row" data-v-5f0f6176="">
<div class="cols" data-v-5f0f6176="">
<div data-v-5f0f6176=""><label data-v-5f0f6176="">Track</label><input placeholder="Leave unchanged" data-testid="track-input" name="track" pattern="\\d*" title="Empty or a number" type="text" data-v-5f0f6176=""></div>
<div data-v-5f0f6176=""><label data-v-5f0f6176="">Disc</label><input placeholder="Leave unchanged" data-testid="disc-input" name="disc" pattern="\\d*" title="Empty or a number" type="text" data-v-5f0f6176=""></div>
</div>
</div>
</div>
<!--v-if-->
</div>
</div>
<footer data-v-5f0f6176=""><button type="submit" data-v-27deb898="" data-v-5f0f6176="">Update</button><button type="button" class="btn-cancel" white="" data-v-27deb898="" data-v-5f0f6176="">Cancel</button></footer>
</form>
</div>
`;

View file

@ -112,6 +112,7 @@ export const songStore = {
scrobble: async (song: Song) => await httpService.post(`${song.id}/scrobble`, { timestamp: song.play_start_time }),
async update (songsToUpdate: Song[], data: any) {
console.log(data)
const { songs, artists, albums, removed } = await httpService.put<SongUpdateResult>('songs', {
data,
songs: songsToUpdate.map(song => song.id)

View file

@ -34,7 +34,7 @@ export const requireInjection = <T> (key: InjectionKey<T>, defaultValue?: T) =>
const value = inject(key, defaultValue)
if (typeof value === 'undefined') {
throw new Error(`Missing injection: ${key}`)
throw new Error(`Missing injection: ${key.toString()}`)
}
return value

View file

@ -2692,11 +2692,6 @@ deep-is@~0.1.3:
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
deepmerge@^4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
define-properties@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"