chore: refactor event bus and mixins

This commit is contained in:
Phan An 2022-04-15 16:24:30 +02:00
parent 8a2e88a49a
commit 1ab5837c76
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
314 changed files with 27402 additions and 4678 deletions

View file

@ -1,2 +0,0 @@
libs
tests

3
.gitmodules vendored
View file

@ -1,3 +0,0 @@
[submodule "resources/assets"]
path = resources/assets
url = https://github.com/koel/core.git

View file

@ -13,42 +13,106 @@
"type": "git",
"url": "https://github.com/koel/koel"
},
"dependencies": {
"alertify.js": "^1.0.12",
"axios": "^0.21.1",
"blueimp-md5": "^2.3.0",
"compare-versions": "^3.5.1",
"font-awesome": "^4.7.0",
"intersection-observer": "^0.2.0",
"ismobilejs": "^0.4.0",
"local-storage": "^2.0.0",
"lodash": "^4.17.19",
"mitt": "^3.0.0",
"nouislider": "^14.0.2",
"nprogress": "^0.2.0",
"plyr": "1.5.x",
"pusher-js": "^4.1.0",
"select": "^1.0.6",
"sketch-js": "^1.1.3",
"slugify": "^1.0.2",
"vue": "^3.2.32",
"vue-global-events": "^1.0.2",
"vue-virtual-scroller": "^2.0.0-alpha.1",
"vuequery": "~2.1.1",
"youtube-player": "^3.0.4"
},
"devDependencies": {
"@testing-library/cypress": "^7.0.6",
"@babel/core": "^7.9.6",
"@babel/polyfill": "^7.8.7",
"@babel/preset-env": "^7.9.6",
"@testing-library/cypress": "^8.0.2",
"@types/axios": "^0.14.0",
"@types/blueimp-md5": "^2.7.0",
"@types/faker": "^4.1.11",
"@types/jest": "^26",
"@types/local-storage": "^1.4.0",
"@types/lodash": "^4.14.150",
"@types/node": "^13.13.4",
"@types/nprogress": "^0.2.0",
"@types/pusher-js": "^4.2.2",
"@types/wicg-mediasession": "^1.1.0",
"@types/youtube-player": "^5.5.2",
"@typescript-eslint/eslint-plugin": "^4.11.1",
"@typescript-eslint/parser": "^4.11.1",
"cross-env": "^3.2.3",
"cypress": "^7.3.0",
"@vue/compiler-sfc": "^3.2.32",
"@vue/eslint-config-standard": "^5.1.2",
"@vue/eslint-config-typescript": "^5.0.2",
"@vue/test-utils": "^1.0.0-beta.25",
"babel-core": "^7.0.0-bridge.0",
"babel-loader": "^8.1.0",
"cross-env": "^7.0.3",
"crypto-random-string": "^1.0.0",
"css-loader": "^0.28.7",
"cypress": "^9.5.4",
"cypress-file-upload": "^4.1.1",
"eslint": "^7.17.0",
"eslint-config-vue": "^2.0.1",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-jest": "^22.0.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.1",
"eslint-plugin-vue": "^6.2.2",
"factoria": "^3.1.3",
"file-loader": "^1.1.6",
"font-awesome": "^4.7.0",
"husky": "^4.2.5",
"kill-port": "^1.6.1",
"laravel-mix": "^5.0.4",
"laravel-mix": "^6.0.43",
"lint-staged": "^10.3.0",
"postcss": "^8.4.12",
"resolve-url-loader": "^3.1.1",
"sass": "^1.26.5",
"sass-loader": "^8.0.2",
"sass": "^1.50.0",
"sass-loader": "^12.6.0",
"start-server-and-test": "^1.11.7",
"ts-loader": "^7.0.1",
"typescript": "^3.8.3",
"vue-template-compiler": "^2.6.11",
"webpack": "^4.42.1",
"webpack-node-externals": "^1.6.0"
"ts-loader": "^9.2.8",
"typescript": "^4.6.3",
"vue-loader": "^16.2.0",
"vue-template-compiler": "^2.6.14",
"vue-test-helpers": "^2.0.0",
"webpack": "^5.72.0",
"webpack-node-externals": "^3.0.0"
},
"scripts": {
"lint": "eslint ./cypress/**/*.ts",
"watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch-poll": "yarn watch -- --watch-poll",
"hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
"dev": "start-test 'php artisan serve --port=8000 --quiet' :8000 hot",
"watch.bak": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch-poll.bak": "yarn watch -- --watch-poll",
"hot.bak": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
"dev.bak": "start-test 'php artisan serve --port=8000 --quiet' :8000 hot",
"test:e2e": "kill-port 8080 && start-test dev :8080 'cypress open'",
"test:e2e:ci": "kill-port 8080 && start-test 'php artisan serve --port=8080 --quiet' :8080 'cypress run'",
"build": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"build-demo": "cross-env NODE_ENV=demo node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js -p",
"production": "yarn build"
"production.bak": "yarn build",
"dev": "npm run development",
"development": "mix",
"watch": "mix watch",
"watch-poll": "mix watch -- --watch-options-poll=1000",
"hot": "mix watch --hot",
"prod": "npm run production",
"production": "mix --production"
},
"dependencies": {},
"husky": {
"hooks": {
"pre-commit": "lint-staged"

@ -1 +0,0 @@
Subproject commit 853396f2b17cfaa420de772d5534f8eb2fce5ff2

View file

@ -0,0 +1,5 @@
{
"presets": [
"@babel/preset-env"
]
}

View file

@ -0,0 +1,2 @@
js/libs
js/tests/__coverage__

View file

@ -1,14 +1,26 @@
{
"root": true,
"parser": "vue-eslint-parser",
"env": {
"browser": true
},
"parserOptions": {
"parser": "@typescript-eslint/parser",
"ecmaVersion": 2020
},
"extends": [
"plugin:vue/essential",
"@vue/standard",
"@vue/typescript/recommended"
],
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"globals": {
"KOEL_ENV": true,
"NODE_ENV": true,
"HTMLElement": true,
"FileReader": true
},
"rules": {
"camelcase": 0,
"no-multi-str": 0,
@ -21,6 +33,8 @@
"@typescript-eslint/no-inferrable-types": 0,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/ban-ts-ignore": 0
"@typescript-eslint/ban-ts-ignore": 0,
"vue/no-side-effects-in-computed-properties": 0,
"vue/valid-v-on": 0
}
}

4
resources/assets/.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,4 @@
# These are supported funding model platforms
github: [phanan]
open_collective: [koel]

View file

@ -0,0 +1,10 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
Hey ya! Thanks for contributing to this project. Unless the issue is _very_ specific to this repo, you may want to report it under the main [phanan/koel](https://github.com/phanan/koel/issues/new/choose) instead. Cheers!

View file

@ -0,0 +1,23 @@
name: main
on: [ push, workflow_dispatch ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [ 10, 11, 12, 13, 14, 15 ]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: yarn install
- name: Run static type checking
run: yarn type-check
- name: Run unit tests
run: yarn test --forceExit # Jest won't exit in Node 15 for whatever reason
- name: Collect coverage
uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}

35
resources/assets/.gitignore vendored Normal file
View file

@ -0,0 +1,35 @@
node_modules
### Node ###
# Logs
logs
*.log
npm-debug.log*node_modules
### Node ###
# Logs
logs
*.log
npm-debug.log*
### OSX ###
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
### Sass ###
.sass-cache/
*.css.map
.nyc_output
*.swp
*.swo
*~
__coverage__

View file

@ -0,0 +1,3 @@
# koel-core [![Build Status](https://github.com/koel/core/workflows/main/badge.svg)](https://github.com/koel/core/actions) [![codecov](https://codecov.io/gh/koel/core/branch/master/graph/badge.svg)](https://codecov.io/gh/koel/core)
The core components and assets shared by the [web](https://github.com/koel/koel) and [desktop](https://github.com/koel/app) versions of Koel.

View file

@ -0,0 +1 @@
html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:'';content:none}table{border-collapse:collapse;border-spacing:0}

View file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

20
resources/assets/img/itunes.svg Executable file
View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="176px" height="177px" viewBox="0 0 176 177" enable-background="new 0 0 176 177" xml:space="preserve">
<g>
<g>
<path fill="#fff" d="M88,0.5c-48.602,0-88,39.399-88,88c0,48.601,39.398,88,88,88c48.601,0,88-39.399,88-88
C176,39.899,136.601,0.5,88,0.5z M88,169.108c-44.52,0-80.608-36.09-80.608-80.608C7.392,43.981,43.48,7.892,88,7.892
c44.519,0,80.608,36.089,80.608,80.608C168.608,133.019,132.519,169.108,88,169.108z M126.254,31.462
c-1.221-1.033-3.796-0.399-3.796-0.399L70.117,41.451c0,0-2.253,0.057-3.795,1.998c-1.083,1.363-0.999,3.796-0.999,3.796v62.129
c0,0,0.188,2.209-1.398,3.796c-1.843,1.842-6.479,2.14-10.588,3.196c-6.84,1.76-13.585,4.634-13.585,12.985
c0,5.86,2.965,13.186,13.186,13.186c12.313,0,18.578-6.789,18.578-15.982c0-5.952,0-7.392,0-7.392l0.2-47.346
c0,0-0.299-2.608,0.6-3.596c1.264-1.389,3.995-1.598,3.995-1.598l40.354-8.19c0,0,2.452-0.845,3.596,0
c1.154,0.853,0.999,3.396,0.999,3.396v36.159c0,0,0.219,2.379-0.999,3.596c-2.288,2.289-6.141,2.242-9.988,3.196
c-7.225,1.792-14.783,4.386-14.783,13.785c0,9.929,9.67,12.785,12.785,12.785c13.62,0,19.378-7.352,19.378-16.182V35.458
C127.652,35.458,127.622,32.62,126.254,31.462z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View file

@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid" width="443" height="443" viewBox="0 0 443 443">
<defs>
<style>
.cls-1, .cls-4 {
fill: #fff;
}
.cls-1, .cls-2, .cls-3 {
fill-rule: evenodd;
}
.cls-2 {
fill: #000;
}
.cls-3 {
fill: #fa0000;
}
</style>
</defs>
<g>
<path d="M341.199,181.514 C341.199,181.514 329.240,198.397 364.162,215.016 C364.162,215.016 310.908,209.376 296.786,195.998 C296.786,195.998 204.949,208.024 241.644,296.184 C241.644,296.184 211.447,274.452 202.192,246.099 C202.192,246.099 133.385,318.336 179.528,436.617 C179.528,436.617 129.141,382.779 122.046,329.902 C122.046,329.902 117.782,338.194 119.576,350.880 C119.576,350.880 99.298,292.397 104.504,261.508 C104.504,261.508 92.138,278.643 91.378,288.332 C91.378,288.332 93.107,266.615 101.661,253.245 C101.661,253.245 99.170,233.867 107.680,213.575 C116.171,193.332 113.463,171.640 111.827,167.782 C111.827,167.782 109.198,176.572 105.433,180.351 C105.433,180.351 117.471,132.757 112.356,109.929 C112.356,109.929 101.837,120.099 103.373,145.597 C103.373,145.597 97.287,123.077 103.298,98.997 C103.298,98.997 97.916,99.526 97.476,105.016 C97.476,105.016 91.763,89.607 99.801,71.864 C99.801,71.864 53.861,41.051 49.370,37.405 C49.370,37.405 42.748,34.052 34.576,34.782 C60.449,24.014 94.715,42.762 115.899,56.843 C127.423,64.850 133.085,69.917 145.649,73.271 C158.380,76.669 169.714,87.898 169.714,87.898 C156.987,69.315 152.956,79.582 118.199,54.835 C68.767,19.873 41.714,29.300 32.237,35.104 C31.001,35.334 29.742,35.658 28.475,36.118 C28.475,36.118 48.041,-5.329 130.152,21.206 C130.152,21.206 142.859,8.268 150.718,7.000 C150.718,7.000 139.765,14.805 137.775,21.256 C137.775,21.256 153.368,9.960 166.082,10.086 C166.082,10.086 151.494,17.506 144.164,24.376 C144.164,24.376 153.849,19.212 167.805,19.383 C167.805,19.383 159.135,23.576 156.156,25.016 C155.536,25.315 155.162,25.496 155.162,25.496 C155.162,25.496 163.938,23.635 168.699,23.535 C168.699,23.535 192.437,12.135 213.904,14.367 C213.904,14.367 206.439,14.422 196.089,22.692 C196.089,22.692 228.441,20.998 250.117,31.701 C250.117,31.701 231.766,28.100 220.340,28.180 C220.340,28.180 276.182,30.408 322.808,91.274 C371.863,155.310 373.908,160.510 413.969,190.628 C413.969,190.628 365.515,188.519 341.199,181.514 Z" class="cls-1"/>
<path d="M307.000,158.000 C307.000,158.000 298.389,172.789 324.000,184.000 C324.000,184.000 284.902,183.184 274.000,173.000 C274.000,173.000 202.346,191.137 234.000,261.000 C234.000,261.000 209.486,246.084 201.000,223.000 C201.000,223.000 147.602,292.965 190.000,382.000 C190.000,382.000 146.674,346.092 138.000,303.000 C138.000,303.000 134.801,310.563 137.000,321.000 C137.000,321.000 116.229,273.617 119.000,246.000 C119.000,246.000 109.084,262.424 109.000,271.000 C109.000,271.000 109.211,251.727 116.000,239.000 C116.000,239.000 112.623,221.998 119.000,203.000 C125.377,184.002 121.697,164.428 120.000,161.000 C120.000,161.000 118.160,169.258 115.000,173.000 C115.000,173.000 123.014,128.197 117.000,107.000 C117.000,107.000 107.994,117.129 111.000,141.000 C111.000,141.000 103.994,120.180 108.000,97.000 C108.000,97.000 103.051,97.730 103.000,103.000 C103.000,103.000 96.660,88.441 103.000,71.000 C103.000,71.000 56.725,41.609 52.000,38.000 C52.000,38.000 45.130,34.687 37.006,35.551 C61.771,24.269 96.266,42.627 117.000,56.000 C128.061,63.455 133.500,68.163 145.000,71.000 C156.500,73.837 167.000,84.000 167.000,84.000 C154.894,66.930 151.844,76.752 119.000,54.000 C69.846,20.172 43.925,29.888 34.829,35.879 C33.563,36.132 32.281,36.495 31.000,37.000 C31.000,37.000 47.998,-5.445 128.000,21.000 C128.000,21.000 138.914,8.246 146.000,7.000 C146.000,7.000 136.453,14.670 135.000,21.000 C135.000,21.000 148.561,9.895 160.000,10.000 C160.000,10.000 147.270,17.262 141.000,24.000 C141.000,24.000 149.486,18.910 162.000,19.000 C162.000,19.000 154.466,23.109 151.868,24.526 C151.327,24.822 151.000,25.000 151.000,25.000 C151.000,25.000 158.766,23.131 163.000,23.000 C163.000,23.000 183.369,11.930 202.000,14.000 C202.000,14.000 195.598,14.076 187.000,22.000 C187.000,22.000 214.584,20.193 233.000,30.000 C233.000,30.000 217.627,26.824 208.000,27.000 C208.000,27.000 254.348,28.752 292.000,82.000 C329.652,135.248 330.877,138.893 359.000,161.000 C359.000,161.000 324.770,162.277 307.000,158.000 Z" class="cls-2"/>
<path d="M118.000,37.000 C118.000,37.000 125.482,30.131 128.000,41.000 C128.000,41.000 124.428,44.504 122.000,43.000 C119.572,41.496 118.000,37.000 118.000,37.000 Z" class="cls-1"/>
<g>
<path d="M184.000,67.000 C184.000,67.000 189.596,53.143 207.000,58.000 C221.245,62.388 222.000,74.000 222.000,74.000 C222.000,74.000 215.773,89.560 204.000,88.000 C192.227,86.440 184.492,76.630 184.000,67.000 Z" class="cls-3"/>
<path d="M200.055,61.063 C207.079,60.796 213.823,64.926 214.031,70.403 C214.243,75.962 207.620,79.457 200.388,78.313 C194.177,77.332 189.733,73.180 189.712,68.967 C189.691,64.801 194.001,61.293 200.055,61.063 Z" class="cls-2"/>
<ellipse cx="196.5" cy="65" rx="4.5" ry="4" class="cls-4"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 800" xmlns:v="https://vecta.io/nano"><path fill="#5b0f6e" d="M486 705.8c-109.3-21.8-223.4-32.2-335.3-19.4C99.5 692.1 49 703 0 719.8V800h843.8c-115.9-33.2-230.8-68.1-347.6-92.2l-10.2-2z"/><path fill="#540c68" d="M1600 0H0v719.8C49 703 99.5 692 150.7 686.3c111.9-12.7 226-2.4 335.3 19.4l10.2 2c116.8 24 231.7 59 347.6 92.2H1600V0z"/><path fill="#4d0861" d="M478.4 581l9.5 2.5C684.1 636 876.6 717 1081.4 760.1c174.2 36.6 349.5 29.2 518.6-10.2V0H0v574.9c52.3-17.6 106.5-27.7 161.1-30.9 107.3-6.6 214.6 10.2 317.3 37z"/><path fill="#45055a" d="M0 0v429.4c55.6-18.4 113.5-27.3 171.4-27.7 102.8-.8 203.2 22.7 299.3 54.5l8.9 3c183.6 62 365.7 146.1 562.4 192.1 186.7 43.7 376.3 34.4 557.9-12.6V0H0z"/><path fill="#3e0354" d="M181.8 259.4c98.2 6 191.9 35.2 281.3 72.1 2.8 1.1 5.5 2.3 8.3 3.4 171 71.6 342.7 158.5 531.3 207.7 198.8 51.8 403.4 40.8 597.3-14.8V0H0v283.2a483.5 483.5 0 0 1 181.8-23.8z"/><path fill="#340745" d="M1600 0H0v136.3c62.3-20.9 127.7-27.5 192.2-19.2 93.6 12.1 180.5 47.7 263.3 89.6l7.7 3.9c158.4 81.1 319.7 170.9 500.3 223.2 210.5 61 430.8 49 636.6-16.6V0z"/><path fill="#2a0a36" d="M454.9 86.3C600.7 177 751.6 269.3 924.1 325c208.6 67.4 431.3 60.8 637.9-5.3 12.8-4.1 25.4-8.4 38.1-12.9V0h-1312c56 21.3 108.7 50.6 159.7 82 2.4 1.4 4.7 2.9 7.1 4.3z"/><path fill="#210a28" d="M1600 0H498c118.1 85.8 243.5 164.5 386.8 216.2c191.8 69.2 400 74.7 595 21.1c40.8-11.2 81.1-25.2 120.3-41.7V0z"/><path fill="#18061a" d="M1397.5 154.8c47.2-10.6 93.6-25.3 138.6-43.8 21.7-8.9 43-18.8 63.9-29.5V0H643.4c62.9 41.7 129.7 78.2 202.1 107.4 174.9 70.7 368.7 88.7 552 47.4z"/><path fill="#0a0109" d="M1315.3 72.4c75.3-12.6 148.9-37.1 216.8-72.4h-723c157.7 71 335.6 101 506.2 72.4z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400" xmlns:v="https://vecta.io/nano"><defs><radialGradient id="A" cx="396" cy="281" r="514" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d18"/><stop offset="1" stop-color="#300"/></radialGradient><linearGradient id="B" gradientUnits="userSpaceOnUse" x1="400" y1="148" x2="400" y2="333"><stop offset="0" stop-color="#fa3" stop-opacity="0"/><stop offset="1" stop-color="#fa3" stop-opacity=".5"/></linearGradient></defs><path fill="url(#A)" d="M0 0h800v400H0z"/><g fill-opacity=".4" fill="url(#B)"><circle cx="267.5" cy="61" r="300"/><circle cx="532.5" cy="61" r="300"/><circle cx="400" cy="30" r="300"/></g></svg>

After

Width:  |  Height:  |  Size: 685 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
resources/assets/img/tile.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View file

@ -0,0 +1,40 @@
/**
* By default jest doesn't transform files in node_modules.
* List names of the libraries we want to whitelist here, e.g., those export ES6 modules.
*/
const forceTransformModules = [
'@phanan/vuebus'
]
module.exports = {
moduleFileExtensions: [
'ts',
'js',
'vue'
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/js/$1'
},
transform: {
'^.+\\.[tj]s$': 'ts-jest',
'.*\\.(vue)$': 'vue-jest',
'^.+\\.(svg|gif|jpg|png)$': '<rootDir>/js/__tests__/__transformers__/image.js'
},
snapshotSerializers: [
'<rootDir>/node_modules/jest-serializer-vue'
],
testMatch: ['**/__tests__/**/*.spec.ts'],
transformIgnorePatterns: [
`node_modules/(?!(${forceTransformModules.join('|')})/)`
],
globals: {
KOEL_ENV: 'web',
NODE_ENV: 'test'
},
setupFilesAfterEnv: ['<rootDir>/js/__tests__/setup.ts'],
verbose: true,
collectCoverage: true,
coverageReporters: ['lcov', 'json', 'html'],
coverageDirectory: '<rootDir>/js/__tests__/__coverage__',
coveragePathIgnorePatterns: ['/node_modules/', '/__tests__/', '/stubs/', '/libs/']
}

View file

@ -0,0 +1,13 @@
{
"plugins": ["jest"],
"env": {
"jest/globals": true
},
"globals": {
"noop": true,
"shallow": true,
"mount": true,
"Vue": true,
"Event": true
}
}

View file

@ -0,0 +1,2 @@
export * from './noop'
export * from './mock'

View file

@ -0,0 +1,18 @@
import FunctionNames = jest.FunctionPropertyNames
import { noop } from '@/utils'
export const mock = <T extends {}, M extends FunctionNames<Required<T>>>(
object: T,
method: M,
implementation: any = noop
) => {
const m = jest.spyOn(object, method)
if (implementation instanceof Function) {
m.mockImplementation(implementation)
} else {
m.mockImplementation((): any => implementation)
}
return m
}

View file

@ -0,0 +1,2 @@
/* eslint @typescript-eslint/no-empty-function: 0 */
export const noop = () => {}

View file

@ -0,0 +1,8 @@
export default {
get: jest.fn((): Promise<void> => Promise.resolve()),
post: jest.fn((): Promise<void> => Promise.resolve()),
patch: jest.fn((): Promise<void> => Promise.resolve()),
put: jest.fn((): Promise<void> => Promise.resolve()),
delete: jest.fn((): Promise<void> => Promise.resolve()),
request: jest.fn(() => Promise.resolve({ data: [] }))
}

View file

@ -0,0 +1,14 @@
/* eslint @typescript-eslint/no-unused-vars: 0 */
import _, { Cancelable } from 'lodash'
_.orderBy = jest.fn(<T>(collection: T[]): T[] => collection)
_.shuffle = jest.fn(<T>(collection: T[]): T[] => collection)
_.throttle = jest.fn((fn: Function, wait: number): any => fn)
_.sample = jest.fn(<T>(collection: T[]): T | undefined => {
return collection.length ? collection[0] : undefined
})
module.exports = _

View file

@ -0,0 +1,9 @@
module.exports = {
process () {
return 'module.exports = {};'
},
getCacheKey () {
return 'imageTransform'
}
}

View file

@ -0,0 +1,35 @@
import Vue from 'vue'
import { Wrapper as BaseWrapper, WrapperArray as BaseWrapperArray, VueClass } from '@vue/test-utils/types/index'
import { mount as baseMount, shallowMount, MountOptions } from '@vue/test-utils'
export interface Wrapper extends BaseWrapper<Vue> {
readonly vm: Vue
value: string
has(what: any): boolean
html(): string
text(): string
click(selector?: string, options?: any): Wrapper
change(selector?: string): Wrapper
dblclick(selector?: string): Wrapper
submit (selector?: string): Wrapper
find(any: any): Wrapper
setValue(value: string): Wrapper
input(selector?: string, options?: any): Wrapper
blur(selector?: string): Wrapper
hasAll(...args: any): Wrapper
hasNone(...args: any): Wrapper
findAll(...args: any): WrapperArray
hasEmitted(event: string, data?: any): Wrapper
}
export interface WrapperArray extends BaseWrapperArray<Vue> {
at(index: number): Wrapper
}
export const mount = (component: VueClass<Vue>, options: MountOptions<Vue> = {}): Wrapper => {
return baseMount(component, options) as Wrapper
}
export const shallow = (component: VueClass<Vue>, options: MountOptions<Vue> = {}): Wrapper => {
return shallowMount(component, options) as Wrapper
}

View file

@ -0,0 +1,222 @@
import factory from '@/__tests__/factory'
const currentUser = factory<User>('user', {
id: 1,
name: 'Phan An',
email: 'me@phanan.net',
is_admin: true
})
export default {
artists: [
{
id: 1,
name: 'Unknown Artist'
},
{
id: 2,
name: 'Various Artists'
},
{
id: 3,
name: 'All-4-One'
},
{
id: 4,
name: 'Boy Dylan'
},
{
id: 5,
name: 'James Blunt'
}
],
albums: [
{
id: 1193,
artist_id: 3,
name: 'All-4-One',
cover: '/img/covers/565c0f7067425.jpeg'
},
{
id: 1194,
artist_id: 3,
name: 'And The Music Speaks',
cover: '/img/covers/unknown-album.png'
},
{
id: 1195,
artist_id: 3,
name: 'Space Jam',
cover: '/img/covers/565c0f7115e0f.png'
},
{
id: 1217,
artist_id: 4,
name: 'Highway 61 Revisited',
cover: '/img/covers/565c0f76dc6e8.jpeg'
},
{
id: 1218,
artist_id: 4,
name: 'Pat Garrett & Billy the Kid',
cover: '/img/covers/unknown-album.png'
},
{
id: 1219,
artist_id: 4,
name: "The Times They Are A-Changin",
cover: '/img/covers/unknown-album.png'
},
{
id: 1268,
artist_id: 5,
name: 'Back To Bedlam',
cover: '/img/covers/unknown-album.png'
}
],
songs: [
{
id: '39189f4545f9d5671fb3dc964f0080a0',
album_id: 1193,
artist_id: 3,
title: 'I Swear',
length: 259.92,
playCount: 4
},
{
id: 'a6a550f7d950d2a2520f9bf1a60f025a',
album_id: 1194,
artist_id: 3,
title: 'I can love you like that',
length: 262.61,
playCount: 2
},
{
id: 'd86c30fd34f13c1aff8db59b7fc9c610',
album_id: 1195,
artist_id: 3,
title: 'I turn to you',
length: 293.04
},
{
id: 'e6d3977f3ffa147801ca5d1fdf6fa55e',
album_id: 1217,
artist_id: 4,
title: 'Like a rolling stone',
length: 373.63
},
{
id: 'aa16bbef6a9710eb9a0f41ecc534fad5',
album_id: 1218,
artist_id: 4,
title: "Knockin' on heaven's door",
length: 151.9
},
{
id: 'cb7edeac1f097143e65b1b2cde102482',
album_id: 1219,
artist_id: 4,
title: "The times they are a-changin'",
length: 196
},
{
id: '0ba9fb128427b32683b9eb9140912a70',
album_id: 1268,
artist_id: 5,
title: 'No bravery',
length: 243.12
},
{
id: '123fd1ad32240ecab28a4e86ed5173',
album_id: 1268,
artist_id: 5,
title: 'So long, Jimmy',
length: 265.04
},
{
id: '6a54c674d8b16732f26df73f59c63e21',
album_id: 1268,
artist_id: 5,
title: 'Wisemen',
length: 223.14
},
{
id: '6df7d82a9a8701e40d1c291cf14a16bc',
album_id: 1268,
artist_id: 5,
title: 'Goodbye my lover',
length: 258.61
},
{
id: '74a2000d343e4587273d3ad14e2fd741',
album_id: 1268,
artist_id: 5,
title: 'High',
length: 245.86
},
{
id: '7900ab518f51775fe6cf06092c074ee5',
album_id: 1268,
artist_id: 5,
title: "You're beautiful",
length: 213.29
},
{
id: '803910a51f9893347e087af851e38777',
album_id: 1268,
artist_id: 5,
title: 'Cry',
length: 246.91
},
{
id: 'd82b0d4d4803ebbcb61000a5b6a868f5',
album_id: 1268,
artist_id: 5,
title: 'Tears and rain',
length: 244.45
}
],
interactions: [
{
id: 1,
song_id: '7900ab518f51775fe6cf06092c074ee5',
liked: false,
play_count: 1
},
{
id: 2,
song_id: '95c0ffc33c08c8c14ea5de0a44d5df3c',
liked: false,
play_count: 2
},
{
id: 3,
song_id: 'c83b201502eb36f1084f207761fa195c',
liked: false,
play_count: 1
},
{
id: 4,
song_id: 'cb7edeac1f097143e65b1b2cde102482',
liked: true,
play_count: 3
},
{
id: 5,
song_id: 'ccc38cc14bb95aefdf6da4b34adcf548',
liked: false,
play_count: 4
}
],
currentUser,
users: [
currentUser,
factory<User>('user', {
id: 2,
name: 'John Doe',
email: 'john@doe.tld',
is_admin: false
})
]
}

View file

@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`componnents/song/track-list-item renders 1`] = `
<li title="" role="button" tabindex="0" class=""><span class="no">2</span> <span class="title">Foo and bar</span> <a href="http://koel.local/itunes/song/42?q=Foo%20and%20bar&amp;api_token=abcdef" target="_blank" title="View on iTunes" class="view-on-itunes">
iTunes
</a> <span class="length">00:42</span></li>
`;

View file

@ -0,0 +1,50 @@
import Component from '@/components/album/card.vue'
import Thumbnail from '@/components/ui/album-artist-thumbnail.vue'
import factory from '@/__tests__/factory'
import { playback, download } from '@/services'
import { sharedStore } from '@/stores'
import { mock } from '@/__tests__/__helpers__'
import { mount, shallow } from '@/__tests__/adapter'
describe('components/album/card', () => {
let album: Album
beforeEach(() => {
album = factory<Album>('album', {
songs: factory<Song>('song', 10)
})
// @ts-ignore
sharedStore.state = { allowDownload: true }
})
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('renders properly', async () => {
const wrapper = mount(Component, { propsData: { album } })
await wrapper.vm.$nextTick()
expect(wrapper.has(Thumbnail)).toBe(true)
const html = wrapper.html()
expect(html).toMatch(album.name)
expect(html).toMatch('10 songs')
})
it('shuffles', () => {
const wrapper = shallow(Component, { propsData: { album } })
const m = mock(playback, 'playAllInAlbum')
wrapper.click('.shuffle-album')
expect(m).toHaveBeenCalledWith(album, true)
})
it('downloads', () => {
const wrapper = shallow(Component, { propsData: { album } })
const m = mock(download, 'fromAlbum')
wrapper.click('.download-album')
expect(m).toHaveBeenCalledWith(album)
})
})

View file

@ -0,0 +1,54 @@
import Component from '@/components/album/context-menu.vue'
import factory from '@/__tests__/factory'
import { playback, download } from '@/services'
import { sharedStore } from '@/stores'
import { mock } from '@/__tests__/__helpers__'
import { shallow, mount } from '@/__tests__/adapter'
describe('components/album/context-menu', () => {
let album: Album
beforeEach(() => {
album = factory<Album>('album')
// @ts-ignore
sharedStore.state = { allowDownload: true }
})
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('plays all', () => {
const wrapper = shallow(Component, { propsData: { album } })
const m = mock(playback, 'playAllInAlbum')
wrapper.click('[data-test=play]')
expect(m).toHaveBeenCalledWith(album)
})
it('shuffles', () => {
const wrapper = shallow(Component, { propsData: { album } })
const m = mock(playback, 'playAllInAlbum')
wrapper.click('[data-test=shuffle]')
expect(m).toHaveBeenCalledWith(album, true)
})
it('downloads', async () => {
const wrapper = mount(Component, { propsData: { album } })
await wrapper.vm.$nextTick()
await (wrapper.vm as any).open(0, 0)
const m = mock(download, 'fromAlbum')
wrapper.click('[data-test=download]')
expect(m).toHaveBeenCalledWith(album)
})
it('does not have a download item if not downloadable', () => {
// @ts-ignore
sharedStore.state = { allowDownload: false }
const wrapper = shallow(Component, { propsData: { album } })
expect(wrapper.has('[data-test=download]')).toBe(false)
})
})

View file

@ -0,0 +1,45 @@
import Component from '@/components/album/info.vue'
import AlbumThumbnail from '@/components/ui/album-artist-thumbnail.vue'
import factory from '@/__tests__/factory'
import { shallow, mount } from '@/__tests__/adapter'
describe('components/album/info', () => {
it('displays the info as a sidebar by default', () => {
const wrapper = shallow(Component, {
propsData: {
album: factory('album')
}
})
expect(wrapper.findAll('.album-info.sidebar')).toHaveLength(1)
expect(wrapper.findAll('.album-info.full')).toHaveLength(0)
})
it('can display the info in full mode', () => {
const wrapper = shallow(Component, {
propsData: {
album: factory('album'),
mode: 'full'
}
})
expect(wrapper.findAll('.album-info.sidebar')).toHaveLength(0)
expect(wrapper.findAll('.album-info.full')).toHaveLength(1)
})
it('triggers showing full wiki', () => {
const album = factory<Album>('album')
const wrapper = shallow(Component, {
propsData: { album }
})
wrapper.click('.wiki button.more')
expect(wrapper.html()).toMatch(album.info!.wiki!.full)
})
it('shows the album thumbnail', async () => {
const album = factory('album')
const wrapper = mount(Component, {
propsData: { album }
})
await wrapper.vm.$nextTick()
expect(wrapper.has(AlbumThumbnail)).toBe(true)
})
})

View file

@ -0,0 +1,57 @@
import Component from '@/components/album/track-list-item.vue'
import { sharedStore, songStore, queueStore } from '@/stores'
import { playback, ls } from '@/services'
import factory from '@/__tests__/factory'
import { mock } from '@/__tests__/__helpers__'
import { shallow } from '@/__tests__/adapter'
describe('componnents/song/track-list-item', () => {
let song: Song
const track = {
title: 'Foo and bar',
fmtLength: '00:42'
}
const album = factory('album', { id: 42 })
window.BASE_URL = 'http://koel.local/'
beforeEach(() => {
sharedStore.state.useiTunes = true
song = factory('song')
mock(ls, 'get', 'abcdef')
})
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('renders', () => {
const wrapper = shallow(Component, {
propsData: {
track,
album,
index: 1
}
})
expect(wrapper).toMatchSnapshot()
})
it('plays', () => {
mock(songStore, 'guess', song)
const containsStub = mock(queueStore, 'contains', false)
const queueStub = mock(queueStore, 'queueAfterCurrent')
const playStub = mock(playback, 'play')
shallow(Component, {
propsData: {
track,
album,
index: 1
}
}).click('li')
expect(containsStub).toHaveBeenCalledWith(song)
expect(queueStub).toHaveBeenCalledWith(song)
expect(playStub).toHaveBeenCalledWith(song)
})
})

View file

@ -0,0 +1,17 @@
import Component from '@/components/album/track-list.vue'
import TrackListItem from '@/components/album/track-list-item.vue'
import factory from '@/__tests__/factory'
import { mount } from '@/__tests__/adapter'
describe('components/album/track-list', () => {
it('lists the correct number of tracks', async () => {
const wrapper = mount(Component, {
propsData: {
album: factory('album')
}
})
await wrapper.vm.$nextTick()
expect(wrapper.findAll(TrackListItem)).toHaveLength(2)
})
})

View file

@ -0,0 +1,53 @@
import Component from '@/components/artist/card.vue'
import Thumbnail from '@/components/ui/album-artist-thumbnail.vue'
import factory from '@/__tests__/factory'
import { playback, download } from '@/services'
import { sharedStore } from '@/stores'
import { mock } from '@/__tests__/__helpers__'
import { mount, shallow } from '@/__tests__/adapter'
describe('components/artist/card', () => {
let artist: Artist
beforeEach(() => {
// @ts-ignore
sharedStore.state = { allowDownload: true }
artist = factory<Artist>('artist', {
id: 3, // make sure it's not "Various Artists"
albums: factory<Album>('album', 4),
songs: factory<Song>('song', 16)
})
})
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('renders properly', async () => {
const wrapper = mount(Component, { propsData: { artist } })
await wrapper.vm.$nextTick()
expect(wrapper.has(Thumbnail)).toBe(true)
const html = wrapper.html()
expect(html).toMatch('4 albums')
expect(html).toMatch('16 songs')
expect(html).toMatch(artist.name)
})
it('shuffles', () => {
const wrapper = shallow(Component, { propsData: { artist } })
const playStub = mock(playback, 'playAllByArtist')
wrapper.click('.shuffle-artist')
expect(playStub).toHaveBeenCalledWith(artist, true)
})
it('downloads', () => {
const wrapper = shallow(Component, { propsData: { artist } })
const downloadStub = mock(download, 'fromArtist')
wrapper.click('.download-artist')
expect(downloadStub).toHaveBeenCalledWith(artist)
})
})

View file

@ -0,0 +1,54 @@
import Component from '@/components/artist/context-menu.vue'
import factory from '@/__tests__/factory'
import { playback, download } from '@/services'
import { sharedStore } from '@/stores'
import { mock } from '@/__tests__/__helpers__'
import { shallow, mount } from '@/__tests__/adapter'
describe('components/artist/context-menu', () => {
let artist: Artist
beforeEach(() => {
artist = factory<Artist>('artist')
// @ts-ignore
sharedStore.state = { allowDownload: true }
})
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('plays all', () => {
const wrapper = shallow(Component, { propsData: { artist } })
const m = mock(playback, 'playAllByArtist')
wrapper.click('[data-test=play]')
expect(m).toHaveBeenCalledWith(artist)
})
it('shuffles', () => {
const wrapper = shallow(Component, { propsData: { artist } })
const m = mock(playback, 'playAllByArtist')
wrapper.click('[data-test=shuffle]')
expect(m).toHaveBeenCalledWith(artist, true)
})
it('downloads', async () => {
const wrapper = mount(Component, { propsData: { artist } })
await wrapper.vm.$nextTick()
await (wrapper.vm as any).open(0, 0)
const m = mock(download, 'fromArtist')
wrapper.click('[data-test=download]')
expect(m).toHaveBeenCalledWith(artist)
})
it('does not have a download item if not downloadable', () => {
// @ts-ignore
sharedStore.state = { allowDownload: false }
const wrapper = shallow(Component, { propsData: { artist } })
expect(wrapper.has('[data-test=download]')).toBe(false)
})
})

View file

@ -0,0 +1,45 @@
import Component from '@/components/artist/info.vue'
import ArtistThumbnail from '@/components/ui/album-artist-thumbnail.vue'
import factory from '@/__tests__/factory'
import { shallow, mount } from '@/__tests__/adapter'
describe('components/artist/info', () => {
it('displays the info as a sidebar by default', () => {
const wrapper = shallow(Component, {
propsData: {
artist: factory('artist')
}
})
expect(wrapper.findAll('.artist-info.sidebar')).toHaveLength(1)
expect(wrapper.findAll('.artist-info.full')).toHaveLength(0)
})
it('can display the info in full mode', () => {
const wrapper = shallow(Component, {
propsData: {
artist: factory('artist'),
mode: 'full'
}
})
expect(wrapper.findAll('.artist-info.sidebar')).toHaveLength(0)
expect(wrapper.findAll('.artist-info.full')).toHaveLength(1)
})
it('triggers showing full bio', () => {
const artist = factory<Artist>('artist')
const wrapper = shallow(Component, {
propsData: { artist }
})
wrapper.click('.bio button.more')
expect(wrapper.html()).toMatch(artist.info!.bio!.full)
})
it('shows the artist thumbnail', async () => {
const artist = factory('artist')
const wrapper = mount(Component, {
propsData: { artist }
})
await wrapper.vm.$nextTick()
expect(wrapper.has(ArtistThumbnail)).toBe(true)
})
})

View file

@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/auth/login-form renders properly 1`] = `
<form data-testid="login-form" class="">
<div class="logo"><img src="@/../img/logo.svg" width="156" height="auto" alt="Koel's logo"></div>
<!----> <input type="email" placeholder="Email Address" autofocus="autofocus" required="required"> <input type="password" placeholder="Password" required="required">
<btn-stub type="submit">Log In</btn-stub>
</form>
`;

View file

@ -0,0 +1,26 @@
import Component from '@/components/auth/login-form.vue'
import { userStore } from '@/stores'
import { mock } from '@/__tests__/__helpers__'
import { shallow } from '@/__tests__/adapter'
describe('components/auth/login-form', () => {
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('renders properly', () => {
expect(shallow(Component)).toMatchSnapshot()
})
it('triggers login when form is submitted', () => {
const loginStub = mock(userStore, 'login')
shallow(Component, {
data: () => ({
email: 'john@doe.com',
password: 'secret'
})
}).submit('form')
expect(loginStub).toHaveBeenCalledWith('john@doe.com', 'secret')
})
})

View file

@ -0,0 +1,38 @@
import Component from '@/components/layout/app-header.vue'
import compareVersions from 'compare-versions'
import { eventBus } from '@/utils'
import { mock } from '@/__tests__/__helpers__'
import { shallow } from '@/__tests__/adapter'
import { sharedStore, userStore } from '@/stores'
import factory from '@/__tests__/factory'
describe('components/layout/app-header', () => {
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('toggles sidebar', () => {
const m = mock(eventBus, 'emit')
shallow(Component).click('.hamburger')
expect(m).toHaveBeenCalledWith('TOGGLE_SIDEBAR')
})
it('toggles search form', () => {
const m = mock(eventBus, 'emit')
shallow(Component).click('.magnifier')
expect(m).toHaveBeenCalledWith('TOGGLE_SEARCH_FORM')
})
it.each([[true, true, true], [false, true, false], [true, false, false], [false, false, false]])(
'announces a new version if applicable',
(hasNewVersion, isAdmin, shouldAnnounce) => {
mock(compareVersions, 'compare').mockReturnValue(hasNewVersion)
userStore.state.current = factory<User>('user', {
is_admin: isAdmin
})
const wrapper = shallow(Component)
expect(wrapper.has('[data-test=new-version-available]')).toBe(shouldAnnounce)
}
)
})

View file

@ -0,0 +1,61 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/layout/extra-panel does not have a YouTube tab if not using YouTube 1`] = `
<section id="extra" data-testid="extra-panel" class="text-secondary showing">
<div class="tabs">
<div role="tablist" class="clear"><button aria-selected="true" aria-controls="extraPanelLyrics" id="extraTabLyrics" role="tab">
Lyrics
</button> <button aria-controls="extraPanelArtist" id="extraTabArtist" role="tab">
Artist
</button> <button aria-controls="extraPanelAlbum" id="extraTabAlbum" role="tab">
Album
</button>
<!---->
</div>
<div class="panes">
<div aria-labelledby="extraTabLyrics" id="extraPanelLyrics" role="tabpanel" tabindex="0">
<lyricspane-stub></lyricspane-stub>
</div>
<div aria-labelledby="extraTabArtist" id="extraPanelArtist" role="tabpanel" tabindex="0" style="display: none;">
<!---->
</div>
<div aria-labelledby="extraTabAlbum" id="extraPanelAlbum" role="tabpanel" tabindex="0" style="display: none;">
<!---->
</div>
<div aria-labelledby="extraTabYouTube" id="extraPanelYouTube" role="tabpanel" tabindex="0" style="display: none;">
<!---->
</div>
</div>
</div>
</section>
`;
exports[`components/layout/extra-panel renders properly 1`] = `
<section id="extra" data-testid="extra-panel" class="text-secondary showing">
<div class="tabs">
<div role="tablist" class="clear"><button aria-selected="true" aria-controls="extraPanelLyrics" id="extraTabLyrics" role="tab">
Lyrics
</button> <button aria-controls="extraPanelArtist" id="extraTabArtist" role="tab">
Artist
</button> <button aria-controls="extraPanelAlbum" id="extraTabAlbum" role="tab">
Album
</button>
<!---->
</div>
<div class="panes">
<div aria-labelledby="extraTabLyrics" id="extraPanelLyrics" role="tabpanel" tabindex="0">
<lyrics-pane-stub></lyrics-pane-stub>
</div>
<div aria-labelledby="extraTabArtist" id="extraPanelArtist" role="tabpanel" tabindex="0" style="display: none;">
<!---->
</div>
<div aria-labelledby="extraTabAlbum" id="extraPanelAlbum" role="tabpanel" tabindex="0" style="display: none;">
<!---->
</div>
<div aria-labelledby="extraTabYouTube" id="extraPanelYouTube" role="tabpanel" tabindex="0" style="display: none;">
<!---->
</div>
</div>
</div>
</section>
`;

View file

@ -0,0 +1,153 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`component/layout/main-wrapper/index renders properly 1`] = `
<div id="mainWrapper">
<nav id="sidebar" class="side side-nav showing">
<section class="music">
<h1>Your Music</h1>
<ul class="menu">
<li><a href="#!/home" class="home active">Home</a></li>
<li><a href="#!/queue" class="queue">Current Queue</a></li>
<li><a href="#!/songs" class="songs">All Songs</a></li>
<li><a href="#!/albums" class="albums">Albums</a></li>
<li><a href="#!/artists" class="artists">
Artists
</a></li>
<!---->
</ul>
</section>
<!---->
<!---->
</nav>
<section id="mainContent">
<!---->
<!---->
<section id="homeWrapper">
<!---->
<div class="main-scroll-wrap">
<div class="two-cols">
<!---->
<section class="recent">
<h1>
Recently Played
<!---->
</h1>
<!---->
<p class="text-secondary">
Your recently played songs will be displayed here.<br>
Start listening!
</p>
</section>
</div>
<!---->
<!---->
<!---->
<!---->
</div>
</section>
<section id="queueWrapper" style="display: none;">
<!---->
<!---->
</section>
<section id="songsWrapper" style="display: none;">
<!---->
<div tabindex="0" class="song-list-wrap main-scroll-wrap all-songs">
<table class="song-list-header sortable">
<thead>
<tr>
<th class="track-number">
#
<i class="fa fa-angle-down" style="display: none;"></i> <i class="fa fa-angle-up" style="display: none;"></i></th>
<th class="title">
Title
<i class="fa fa-angle-down" style="display: none;"></i> <i class="fa fa-angle-up" style="display: none;"></i></th>
<th class="artist">
Artist
<i class="fa fa-angle-down" style="display: none;"></i> <i class="fa fa-angle-up" style="display: none;"></i></th>
<th class="album">
Album
<i class="fa fa-angle-down" style="display: none;"></i> <i class="fa fa-angle-up" style="display: none;"></i></th>
<th class="time">
Time
<i class="fa fa-angle-down" style="display: none;"></i> <i class="fa fa-angle-up" style="display: none;"></i></th>
<th class="favorite"></th>
<th class="play"></th>
</tr>
</thead>
</table>
<div data-v-3eef485a="" class="virtual-scroller scroller">
<div data-v-3eef485a="" class="item-container">
<table data-v-3eef485a="" class="items"></table>
</div>
<div data-v-b329ee4c="" data-v-3eef485a="" tabindex="-1" class="resize-observer"><object style="display: block; position: absolute; top: 0; left: 0; height: 100%; width: 100%; overflow: hidden; pointer-events: none; z-index: -1;" aria-hidden="true" tabindex="-1" type="text/html" data="about:blank"></object></div>
</div>
</div>
</section>
<section id="albumsWrapper" style="display: none;">
<!---->
<div class="albums main-scroll-wrap as-null">
<!---->
</div>
</section>
<section id="artistsWrapper" style="display: none;">
<!---->
<div class="artists main-scroll-wrap as-null">
<!---->
</div>
</section>
<section id="playlistWrapper" style="display: none;">
<!---->
<!---->
</section>
<section id="favoritesWrapper" style="display: none;">
<!---->
<!---->
</section>
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
</section>
<section id="extra" data-testid="extra-panel" class="text-secondary showing">
<div class="tabs">
<div role="tablist" class="clear"><button aria-selected="true" aria-controls="extraPanelLyrics" id="extraTabLyrics" role="tab">
Lyrics
</button> <button aria-controls="extraPanelArtist" id="extraTabArtist" role="tab">
Artist
</button> <button aria-controls="extraPanelAlbum" id="extraTabAlbum" role="tab">
Album
</button>
<!---->
</div>
<div class="panes">
<div aria-labelledby="extraTabLyrics" id="extraPanelLyrics" role="tabpanel" tabindex="0">
<!---->
</div>
<div aria-labelledby="extraTabArtist" id="extraPanelArtist" role="tabpanel" tabindex="0" style="display: none;">
<!---->
</div>
<div aria-labelledby="extraTabAlbum" id="extraPanelAlbum" role="tabpanel" tabindex="0" style="display: none;">
<!---->
</div>
<div aria-labelledby="extraTabYouTube" id="extraPanelYouTube" role="tabpanel" tabindex="0" style="display: none;">
<!---->
</div>
</div>
</div>
</section>
<div class="modal-wrapper">
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
</div>
</div>
`;

View file

@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/layout/main-wrapper/main-content has a translucent image per song/album 1`] = `
<section id="mainContent">
<!---->
<!---->
<homescreen-stub></homescreen-stub>
<queuescreen-stub style="display: none;"></queuescreen-stub>
<allsongsscreen-stub style="display: none;"></allsongsscreen-stub>
<albumlistscreen-stub style="display: none;"></albumlistscreen-stub>
<artistlistscreen-stub style="display: none;"></artistlistscreen-stub>
<playlistscreen-stub style="display: none;"></playlistscreen-stub>
<favoritesscreen-stub style="display: none;"></favoritesscreen-stub>
<recentlyplayedscreen-stub style="display: none;"></recentlyplayedscreen-stub>
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
</section>
`;

View file

@ -0,0 +1,81 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/layout/main-wrapper/sidebar displays YouTube menu item if using YouTube 1`] = `
<nav id="sidebar" class="side side-nav showing">
<section class="music">
<h1>Your Music</h1>
<ul class="menu">
<li><a href="#!/home" class="home active">Home</a></li>
<li><a href="#!/queue" class="queue">Current Queue</a></li>
<li><a href="#!/songs" class="songs">All Songs</a></li>
<li><a href="#!/albums" class="albums">Albums</a></li>
<li><a href="#!/artists" class="artists">
Artists
</a></li>
<li><a href="#!/youtube" class="youtube">
YouTube Video
</a></li>
</ul>
</section>
<playlistlist-stub current-view="Home"></playlistlist-stub>
<!---->
</nav>
`;
exports[`components/layout/main-wrapper/sidebar displays management menu items for admin 1`] = `
<nav id="sidebar" class="side side-nav showing">
<section class="music">
<h1>Your Music</h1>
<ul class="menu">
<li><a href="#!/home" class="home active">Home</a></li>
<li><a href="#!/queue" class="queue">Current Queue</a></li>
<li><a href="#!/songs" class="songs">All Songs</a></li>
<li><a href="#!/albums" class="albums">Albums</a></li>
<li><a href="#!/artists" class="artists">
Artists
</a></li>
<li><a href="#!/youtube" class="youtube">
YouTube Video
</a></li>
</ul>
</section>
<playlistlist-stub current-view="Home"></playlistlist-stub>
<section class="manage">
<h1>Manage</h1>
<ul class="menu">
<li><a href="#!/settings" class="settings">Settings</a></li>
<li><a href="#!/upload" class="upload">Upload</a></li>
<li><a href="#!/users" class="users">Users</a></li>
</ul>
</section>
</nav>
`;
exports[`components/layout/main-wrapper/sidebar displays new version info 1`] = `
<nav id="sidebar" class="side side-nav showing">
<section class="music">
<h1>Your Music</h1>
<ul class="menu">
<li><a href="#!/home" class="home active">Home</a></li>
<li><a href="#!/queue" class="queue">Current Queue</a></li>
<li><a href="#!/songs" class="songs">All Songs</a></li>
<li><a href="#!/albums" class="albums">Albums</a></li>
<li><a href="#!/artists" class="artists">
Artists
</a></li>
<li><a href="#!/youtube" class="youtube">
YouTube Video
</a></li>
</ul>
</section>
<playlistlist-stub current-view="Home"></playlistlist-stub>
<section class="manage">
<h1>Manage</h1>
<ul class="menu">
<li><a href="#!/settings" class="settings">Settings</a></li>
<li><a href="#!/upload" class="upload">Upload</a></li>
<li><a href="#!/users" class="users">Users</a></li>
</ul>
</section>
</nav>
`;

View file

@ -0,0 +1,55 @@
import Component from '@/components/layout/main-wrapper/extra-panel.vue'
import factory from '@/__tests__/factory'
import { eventBus } from '@/utils'
import { songInfo } from '@/services'
import { mock } from '@/__tests__/__helpers__'
import { shallow, Wrapper } from '@/__tests__/adapter'
const shallowComponent = (data: object = {}): Wrapper => shallow(Component, {
stubs: ['lyrics-pane', 'artist-info', 'album-info', 'you-tube-video-list'],
data: () => data
})
describe('components/layout/extra-panel', () => {
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('renders properly', () => {
expect(shallowComponent()).toMatchSnapshot()
})
it('does not have a YouTube tab if not using YouTube', async () => {
const wrapper = shallowComponent({
sharedState: {
useYouTube: false
}
})
await wrapper.vm.$nextTick()
expect(shallow(Component)).toMatchSnapshot()
})
it('has a YouTube tab if using YouTube', async () => {
const wrapper = shallowComponent({
sharedState: {
useYouTube: true
}
})
await wrapper.vm.$nextTick()
expect(wrapper.has('#extraTabYouTube')).toBe(true)
})
it.each<[string]>([['#extraTabLyrics'], ['#extraTabAlbum'], ['#extraTabArtist']])
('switches to "%s" tab', selector => {
expect(shallowComponent().click(selector).find('[aria-selected=true]').is(selector)).toBe(true)
})
it('fetch song info when a new song is played', () => {
shallowComponent()
const song = factory('song')
const m = mock(songInfo, 'fetch', song)
eventBus.emit('SONG_STARTED', song)
expect(m).toHaveBeenCalledWith(song)
})
})

View file

@ -0,0 +1,10 @@
import Component from '@/components/layout/main-wrapper/index.vue'
import { mount } from '@/__tests__/adapter'
describe('component/layout/main-wrapper/index', () => {
it('renders properly', async () => {
const wrapper = mount(Component)
await wrapper.vm.$nextTick()
expect(wrapper).toMatchSnapshot()
})
})

View file

@ -0,0 +1,17 @@
import Component from '@/components/layout/main-wrapper/main-content.vue'
import { eventBus } from '@/utils'
import factory from '@/__tests__/factory'
import { shallow } from '@/__tests__/adapter'
describe('components/layout/main-wrapper/main-content', () => {
it('has a translucent image per song/album', () => {
const wrapper = shallow(Component)
const song = factory('song', {
album: factory('album', {
cover: 'http://foo/bar.jpg'
})
})
eventBus.emit('SONG_STARTED', song)
expect(wrapper).toMatchSnapshot()
})
})

View file

@ -0,0 +1,39 @@
import Component from '@/components/layout/main-wrapper/sidebar.vue'
import { sharedStore } from '@/stores'
import factory from '@/__tests__/factory'
import { shallow } from '@/__tests__/adapter'
describe('components/layout/main-wrapper/sidebar', () => {
it('displays YouTube menu item if using YouTube', () => {
sharedStore.state.useYouTube = true
expect(shallow(Component)).toMatchSnapshot()
})
it('displays management menu items for admin', async () => {
const wrapper = shallow(Component, {
data: () => ({
userState: {
current: factory('user', { is_admin: true })
}
})
})
await wrapper.vm.$nextTick()
expect(wrapper).toMatchSnapshot()
})
it('displays new version info', async () => {
sharedStore.state.currentVersion = 'v0.0.0'
sharedStore.state.latestVersion = 'v0.0.1'
const wrapper = shallow(Component, {
data: () => ({
userState: {
current: factory('user', { is_admin: true })
}
})
})
await wrapper.vm.$nextTick()
expect(wrapper).toMatchSnapshot()
})
})

View file

@ -0,0 +1,45 @@
import Component from '@/components/layout/modal-wrapper.vue'
import { eventBus } from '@/utils'
import factory from '@/__tests__/factory'
import { shallow } from '@/__tests__/adapter'
import { mock } from '@/__tests__/__helpers__'
import { http } from '@/services'
describe('components/layout/modal-wrapper', () => {
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it.each<[string, string, User | Song | undefined]>([
['add-user-form', 'MODAL_SHOW_ADD_USER_FORM', undefined],
['edit-user-form', 'MODAL_SHOW_EDIT_USER_FORM', factory('user')],
['edit-song-form', 'MODAL_SHOW_EDIT_SONG_FORM', factory('song')],
['create-smart-playlist-form', 'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM', undefined]
])('shows %s modal', async (modalName, eventName, eventParams?) => {
if (modalName === 'edit-song-form') {
// mocking the songInfo.fetch() request made during edit-form modal opening
mock(http, 'request').mockReturnValue(Promise.resolve({ data: {}}))
}
const wrapper = shallow(Component, {
stubs: [modalName]
})
eventBus.emit(eventName, eventParams)
await wrapper.vm.$nextTick()
expect(wrapper.has(`${modalName}-stub`)).toBe(true)
})
it('closes', async () => {
const wrapper = shallow(Component, {
stubs: ['add-user-form']
})
eventBus.emit('MODAL_SHOW_ADD_USER_FORM')
await wrapper.vm.$nextTick()
expect(wrapper.has('add-user-form-stub')).toBe(true)
;(wrapper.vm as any).close()
expect(wrapper.has('add-user-form-stub')).toBe(false)
})
})

View file

@ -0,0 +1,51 @@
import Component from '@/components/meta/about-dialog.vue'
import factory from '@/__tests__/factory'
import { shallow } from '@/__tests__/adapter'
describe('components/meta/about-dialog', () => {
const versionPermutations = [
['v4.0.0'/* latest ver */, 'v4.0.0-beta'/* this ver */, true/* admin */, true/* show new ver notification */],
['v4.0.0', 'v4.0.0', true, false],
['v4.0.0', 'v3.9.0', false, false]
]
it.each(versionPermutations)('new version notification', (latestVersion, currentVersion, isAdmin, visible) => {
const wrapper = shallow(Component, {
data: () => ({
userState: {
current: factory('user', {
is_admin: isAdmin
})
},
sharedState: {
latestVersion,
currentVersion
}
})
})
expect(wrapper.has('.new-version')).toBe(visible)
})
const demoPermutations = [
[true, true],
[false, false]
]
it.each(demoPermutations)('builds demo version with(out) credits', (inDemoEnv, creditVisible) => {
const wrapper = shallow(Component, {
data: () => ({
userState: {
current: factory('user', {
is_admin: true
})
},
sharedState: {
latestVersion: 'v1.0.0',
currentVersion: 'v1.0.0'
},
demo: inDemoEnv
})
})
expect(wrapper.has('.demo-credits')).toBe(creditVisible)
})
})

View file

@ -0,0 +1,29 @@
import Component from '@/components/playlist/name-editor.vue'
import factory from '@/__tests__/factory'
import { playlistStore } from '@/stores'
import { mock } from '@/__tests__/__helpers__'
import { shallow } from '@/__tests__/adapter'
describe('components/playlist/name-editor', () => {
let playlist: Playlist
beforeEach(() => {
playlist = factory<Playlist>('playlist', {
id: 99,
name: 'Foo'
})
})
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('updates a playlist', () => {
const updateStub = mock(playlistStore, 'update')
const wrapper = shallow(Component, {
propsData: { playlist }
})
wrapper.find('[type=text]').setValue('Bar').input().blur()
expect(updateStub).toHaveBeenCalledWith(expect.objectContaining({ id: 99, name: 'Bar' }))
})
})

View file

@ -0,0 +1,41 @@
import Component from '@/components/playlist/sidebar-item.vue'
import factory from '@/__tests__/factory'
import { shallow, mount } from '@/__tests__/adapter'
describe('components/playlist/sidebar-item', () => {
let playlist: Playlist
beforeEach(() => {
playlist = factory<Playlist>('playlist', {
id: 99,
name: 'Foo'
})
})
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('edits a playlist', async () => {
const wrapper = mount(Component, {
propsData: { playlist }
})
wrapper.dblclick('li.playlist')
await wrapper.vm.$nextTick()
expect(wrapper.has('[name=name]')).toBe(true)
})
it("doesn't allow editing Favorites item", async () => {
const wrapper = shallow(Component, {
propsData: {
playlist: { name: 'Favorites' },
type: 'favorites'
}
})
wrapper.dblclick('li.favorites')
await wrapper.vm.$nextTick()
expect(wrapper.has('[name=name]')).toBe(false)
})
})

View file

@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`profile-preferences/profile-form renders 1`] = `
<div data-testid="theme-card-sample" class="theme" style="background-color: rgb(255, 0, 0);">
<div class="name">Sample</div>
</div>
`;

View file

@ -0,0 +1,20 @@
import { mock } from '@/__tests__/__helpers__'
import { preferenceStore as preferences } from '@/stores'
import { shallow } from '@/__tests__/adapter'
import Component from '@/components/profile-preferences/preferences.vue'
import factory from '@/__tests__/factory'
describe('profile-preferences/preferences', () => {
beforeEach(() => preferences.init(factory('user')))
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it.each([['notify'], ['confirm_closing'], ['show_album_art_overlay']])('updates preference "%s"', key => {
const m = mock(preferences, 'save')
shallow(Component).change(`input[name=${key}]`)
expect(m).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,33 @@
import { shallow } from '@/__tests__/adapter'
import Component from '@/components/profile-preferences/theme-card.vue'
describe('profile-preferences/profile-form', () => {
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
const theme: Theme = {
id: 'sample',
thumbnailColor: '#f00'
}
it('renders', () => {
expect(shallow(Component, {
propsData: {
theme
}
})).toMatchSnapshot()
})
it('emits an event when theme is selected', () => {
const wrapper = shallow(Component, {
propsData: {
theme
}
})
wrapper.click('[data-testid=theme-card-sample]')
expect(wrapper.hasEmitted('selected', theme)).toBe(true)
})
})

View file

@ -0,0 +1,18 @@
import List from '@/components/screens/album-list.vue'
import factory from '@/__tests__/factory'
import { mount } from '@/__tests__/adapter'
describe('components/screens/album-list', () => {
it('displays a list of albums', async () => {
const wrapper = mount(List, {
sync: false, // https://github.com/vuejs/vue-test-utils/issues/673,
stubs: ['album-card'],
data: () => ({
albums: factory('album', 5)
})
})
await wrapper.vm.$nextTick()
expect(wrapper.findAll('album-card-stub')).toHaveLength(5)
})
})

View file

@ -0,0 +1,54 @@
import Component from '@/components/screens/album.vue'
import SongList from '@/components/song/list.vue'
import { download, albumInfo as albumInfoService, playback } from '@/services'
import factory from '@/__tests__/factory'
import { mock } from '@/__tests__/__helpers__'
import { mount, shallow } from '@/__tests__/adapter'
describe('components/screens/album', () => {
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('renders properly', async () => {
const album = factory<Album>('album')
const wrapper = mount(Component, {
propsData: { album }
})
await wrapper.vm.$nextTick()
expect(wrapper.hasAll(SongList)).toBe(true)
})
it('loads info from Last.fm', async () => {
const album = factory<Album>('album', {
info: null,
songs: factory<Song>('song', 2)
})
const wrapper = await shallow(Component, {
propsData: { album },
data: () => ({
sharedState: { useLastfm: true }
})
})
const m = mock(albumInfoService, 'fetch')
wrapper.click('a.info')
expect(m).toHaveBeenCalledWith(album)
})
it('allows downloading', () => {
const album = factory<Album>('album', {
songs: factory<Song>('song', 2)
})
const wrapper = shallow(Component, {
propsData: { album },
data: () => ({
sharedState: { allowDownload: true }
})
})
const m = mock(download, 'fromAlbum')
wrapper.click('a.download')
expect(m).toHaveBeenCalledWith(album)
})
})

View file

@ -0,0 +1,15 @@
import Component from '@/components/screens/all-songs.vue'
import SongList from '@/components/song/list.vue'
import factory from '@/__tests__/factory'
import { songStore } from '@/stores'
import { mount } from '@/__tests__/adapter'
describe('components/screens/all-songs', () => {
it('renders properly', async () => {
songStore.all = factory<Song>('song', 10)
const wrapper = mount(Component)
await wrapper.vm.$nextTick()
expect(wrapper.has(SongList)).toBe(true)
})
})

View file

@ -0,0 +1,18 @@
import List from '@/components/screens/artist-list.vue'
import factory from '@/__tests__/factory'
import { mount } from '@/__tests__/adapter'
describe('components/screens/artist-list', () => {
it('displays a list of artists', async () => {
const wrapper = mount(List, {
sync: false, // https://github.com/vuejs/vue-test-utils/issues/673
stubs: ['artist-card'],
data: () => ({
artists: factory<Artist>('artist', 5)
})
})
await wrapper.vm.$nextTick()
expect(wrapper.findAll('artist-card-stub')).toHaveLength(5)
})
})

View file

@ -0,0 +1,63 @@
import Component from '@/components/screens/artist.vue'
import SongList from '@/components/song/list.vue'
import { download, artistInfo as artistInfoService, playback } from '@/services'
import factory from '@/__tests__/factory'
import { mock } from '@/__tests__/__helpers__'
import { mount, shallow } from '@/__tests__/adapter'
describe('components/screens/artist', () => {
let artist: Artist
beforeEach(() => {
artist = factory('artist')
const album = factory<Album>('album', {
artist,
artist_id: artist.id
})
artist.albums = [album]
artist.songs = factory<Song>('song', 5, {
artist,
album,
artist_id: artist.id,
album_id: album.id
})
})
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('renders upon receiving event', async () => {
const wrapper = mount(Component, {
propsData: { artist }
})
await wrapper.vm.$nextTick()
expect(wrapper.has(SongList)).toBe(true)
})
it('loads info from Last.fm', () => {
artist.info = null
const wrapper = shallow(Component, {
propsData: { artist },
data: () => ({
sharedState: { useLastfm: true }
})
})
const m = mock(artistInfoService, 'fetch')
wrapper.click('a.info')
expect(m).toHaveBeenCalledWith(artist)
})
it('allows downloading', () => {
const wrapper = shallow(Component, {
propsData: { artist },
data: () => ({
sharedState: { allowDownload: true }
})
})
const m = mock(download, 'fromArtist')
wrapper.click('a.download')
expect(m).toHaveBeenCalledWith(artist)
})
})

View file

@ -0,0 +1,60 @@
import Component from '@/components/screens/favorites.vue'
import SongList from '@/components/song/list.vue'
import SongListControls from '@/components/song/list-controls.vue'
import { download } from '@/services'
import factory from '@/__tests__/factory'
import { mock } from '@/__tests__/__helpers__'
import { mount, shallow } from '@/__tests__/adapter'
describe('components/screens/favorites', () => {
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('displays the song list if there are favorites', async () => {
const wrapper = mount(Component, {
data: () => ({
state: {
songs: factory('song', 5)
}
})
})
await wrapper.vm.$nextTick()
expect(wrapper.hasAll(SongList, SongListControls)).toBe(true)
expect(wrapper.findAll('div.none')).toHaveLength(0)
})
it('displays a fallback message if there are no favorites', async () => {
const wrapper = mount(Component, {
data: () => ({
state: {
songs: []
}
})
})
await wrapper.vm.$nextTick()
expect(wrapper.has('[data-test=screen-placeholder]')).toBe(true)
})
it('allows downloading', () => {
const m = mock(download, 'fromFavorites')
shallow(Component, {
data: () => ({
state: {
songs: factory('song', 5),
},
sharedState: { allowDownload: true },
meta: {
songCount: 5,
totalLength: '12:34'
}
})
}).click('a.download')
expect(m).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,22 @@
import Home from '@/components/screens/home.vue'
import factory from '@/__tests__/factory'
import { eventBus } from '@/utils'
import { mock } from '@/__tests__/__helpers__'
import { mount } from '@/__tests__/adapter'
describe('components/screens/home', () => {
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('refreshes when a new song is played', async () => {
const wrapper = mount(Home)
await wrapper.vm.$nextTick()
// @ts-ignore
const m = mock(wrapper.vm, 'refreshDashboard')
eventBus.emit('SONG_STARTED', factory('song'))
expect(m).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,52 @@
import Component from '@/components/screens/playlist.vue'
import SongList from '@/components/song/list.vue'
import factory from '@/__tests__/factory'
import { eventBus } from '@/utils'
import { playlistStore } from '@/stores'
import { mock } from '@/__tests__/__helpers__'
import { mount, shallow } from '@/__tests__/adapter'
describe('components/screens/playlist', () => {
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('renders properly', async () => {
const playlist = factory<Playlist>('playlist', { populated: true })
const wrapper = mount(Component, { data: () => ({ playlist }) })
await wrapper.vm.$nextTick()
expect(wrapper.has(SongList)).toBe(true)
})
it('fetch and populate playlist content on demand', () => {
const playlist = factory('playlist', { songs: [] })
shallow(Component)
const m = mock(playlistStore, 'fetchSongs')
eventBus.emit('LOAD_MAIN_CONTENT', 'Playlist', playlist)
expect(m).toHaveBeenCalledWith(playlist)
})
it('displays a fallback message if the playlist is empty', async () => {
const wrapper = mount(Component, {
data: () => ({
playlist: factory('playlist', {
populated: true,
songs: []
})
})
})
await wrapper.vm.$nextTick()
expect(wrapper.has('[data-test=screen-placeholder]')).toBe(true)
})
it('emits an event to delete the playlist', () => {
const playlist = factory('playlist', { populated: true })
const wrapper = shallow(Component, { data: () => ({ playlist }) })
const emitMock = mock(eventBus, 'emit')
wrapper.click('.btn-delete-playlist')
expect(emitMock).toHaveBeenCalledWith('PLAYLIST_DELETE', playlist)
})
})

View file

@ -0,0 +1,90 @@
import Component from '@/components/screens/queue.vue'
import SongList from '@/components/song/list.vue'
import factory from '@/__tests__/factory'
import { queueStore, songStore } from '@/stores'
import { playback } from '@/services'
import { mock } from '@/__tests__/__helpers__'
import { mount, shallow } from '@/__tests__/adapter'
describe('components/screens/queue', () => {
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('renders properly', async () => {
const wrapper = mount(Component, {
data: () => ({
state: {
songs: factory<Song>('song', 10)
}
})
})
await wrapper.vm.$nextTick()
expect(wrapper.has(SongList)).toBe(true)
})
it('prompts to shuffle all songs if there are songs and current queue is empty', async () => {
songStore.state.songs = factory<Song>('song', 10)
const wrapper = mount(Component, {
data: () => ({
state: { songs: [] }
})
})
await wrapper.vm.$nextTick()
expect(wrapper.find('a.start').text()).toMatch('shuffling all songs')
})
it('doesn\'t prompt to shuffle all songs if there is no song', async () => {
songStore.state.songs = []
const wrapper = mount(Component, {
data: () => ({
state: { songs: [] }
})
})
await wrapper.vm.$nextTick()
expect(wrapper.has('a.start')).toBe(false)
})
it('shuffles all songs in the queue if any', () => {
const m = mock(playback, 'queueAndPlay')
const songs = factory<Song>('song', 10)
const wrapper = mount(Component, {
data: () => ({
state: { songs }
})
})
wrapper.click('button.btn-shuffle-all')
expect(m).toHaveBeenCalledWith(songs, true)
})
it('shuffles all available songs if there are no songs queued', () => {
const songs = factory<Song>('song', 10)
songStore.state.songs = songs
const m = mock(playback, 'queueAndPlay')
const c = shallow(Component, {
data: () => ({
state: {
songs: []
}
})
})
c.click('a.start')
expect(m).toHaveBeenCalledWith(songs, true)
})
it('clears the queue', () => {
const m = mock(queueStore, 'clear')
mount(Component, {
data: () => ({
state: { songs: factory('song', 10) }
})
}).click('button.btn-clear-queue')
expect(m).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,34 @@
import Component from '@/components/screens/recently-played.vue'
import SongList from '@/components/song/list.vue'
import factory from '@/__tests__/factory'
import { recentlyPlayedStore } from '@/stores'
import { eventBus } from '@/utils'
import { mock } from '@/__tests__/__helpers__'
import { mount, shallow } from '@/__tests__/adapter'
describe('components/screens/recently-played', () => {
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('renders properly', async () => {
const wrapper = mount(Component, {
data: () => ({
state: {
songs: factory('song', 5)
}
})
})
await wrapper.vm.$nextTick()
expect(wrapper.has(SongList)).toBe(true)
})
it('fetch and populate content on demand', () => {
shallow(Component)
const m = mock(recentlyPlayedStore, 'fetchAll')
eventBus.emit('LOAD_MAIN_CONTENT', 'RecentlyPlayed')
expect(m).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,36 @@
import Component from '@/components/screens/settings.vue'
import { sharedStore, settingStore } from '@/stores'
import { alerts } from '@/utils'
import { mock } from '@/__tests__/__helpers__'
import { shallow } from '@/__tests__/adapter'
describe('components/screens/settings', () => {
beforeEach(() => {
settingStore.state = {
settings: {
media_path: '/foo/'
}
}
})
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('warns if changing a non-empty media path', () => {
sharedStore.state.originalMediaPath = '/bar'
const m = mock(alerts, 'confirm')
shallow(Component).submit('form')
expect(m).toHaveBeenCalled()
})
it("doesn't warn if changing an empty media path", () => {
sharedStore.state.originalMediaPath = ''
const confirmMock = mock(alerts, 'confirm')
const updateMock = mock(settingStore, 'update')
shallow(Component).submit('form')
expect(confirmMock).not.toHaveBeenCalled()
expect(updateMock).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,39 @@
import Component from '@/components/screens/user-list.vue'
import UserCard from '@/components/user/card.vue'
import factory from '@/__tests__/factory'
import { userStore } from '@/stores'
import { eventBus } from '@/utils'
import { mock } from '@/__tests__/__helpers__'
import { mount } from '@/__tests__/adapter'
describe('components/screens/user-list', () => {
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('displays the users', async () => {
userStore.all = factory<User>('user', 10)
const wrapper = mount(Component)
await wrapper.vm.$nextTick()
expect(wrapper.findAll(UserCard)).toHaveLength(10)
})
it('adds new user', async () => {
const emitMock = mock(eventBus, 'emit')
const wrapper = mount(Component)
await wrapper.vm.$nextTick()
wrapper.click('.btn-add')
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_ADD_USER_FORM')
})
it('edits a user', () => {
userStore.all = factory<User>('user', 10)
const emitMock = mock(eventBus, 'emit')
mount(Component).click('.btn-edit')
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_USER_FORM', userStore.all[0])
})
})

View file

@ -0,0 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/song/list-controls renders properly 1`] = `
<div data-test="song-list-controls" class="song-list-controls">
<!---->
<addtomenu-stub config="[object Object]" songs=""></addtomenu-stub>
</div>
`;

View file

@ -0,0 +1,114 @@
import _ from 'lodash'
import Component from '@/components/song/add-to-menu.vue'
import factory from '@/__tests__/factory'
import { playlistStore, queueStore, favoriteStore } from '@/stores'
import { mock } from '@/__tests__/__helpers__'
import { shallow } from '@/__tests__/adapter'
import FunctionPropertyNames = jest.FunctionPropertyNames
describe('components/song/add-to-menu', () => {
const config = {
queue: true,
favorites: true,
playlists: true,
newPlaylist: true
}
let songs: Song[]
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
const initComponent = (customConfig = {}, func = shallow) => {
songs = factory<Song>('song', 5)
return func(Component, {
propsData: {
songs,
config: _.assign(_.clone(config), customConfig),
showing: true
}
})
}
it('renders', () => {
playlistStore.all = factory<Playlist>('playlist', 10)
const wrapper = initComponent()
expect(wrapper.html()).toMatch('Add 5 songs to')
expect(wrapper.hasAll(
'li.after-current',
'li.bottom-queue',
'li.top-queue',
'li.favorites',
'form.form-new-playlist'
)).toBe(true)
expect(wrapper.findAll('li.playlist')).toHaveLength(10)
})
it('supports different configurations', () => {
// add to queue
let wrapper = initComponent({ queue: false })
expect(wrapper.hasNone('li.after-current', 'li.bottom-queue', 'li.top-queue')).toBe(true)
// add to favorites
wrapper = initComponent({ favorites: false })
expect(wrapper.has('li.favorites')).toBe(false)
// add to playlists
wrapper = initComponent({ playlists: false })
expect(wrapper.has('li.playlist')).toBe(false)
// add to a new playlist
wrapper = initComponent({ newPlaylist: false })
expect(wrapper.has('form.form-new-playlist')).toBe(false)
})
it.each<[string, string, FunctionPropertyNames<typeof queueStore>]>([
['after current', '.after-current', 'queueAfterCurrent'],
['to bottom', '.bottom-queue', 'queue'],
['to top', '.top-queue', 'queueToTop']
])('queues songs %s when "%s" is clicked', (to, selector, queueFunc) => {
const wrapper = initComponent()
const queueMock = mock(queueStore, queueFunc)
// @ts-ignore
const closeMock = mock(wrapper.vm, 'close')
wrapper.click(`li${selector}`)
expect(queueMock).toHaveBeenCalledWith(songs)
expect(closeMock).toHaveBeenCalled()
})
it('add songs to favorite', () => {
const wrapper = initComponent()
const likeStub = mock(favoriteStore, 'like')
// @ts-ignore
const closeStub = mock(wrapper.vm, 'close')
wrapper.click('li.favorites')
expect(likeStub).toHaveBeenCalledWith(songs)
expect(closeStub).toHaveBeenCalled()
})
it('add songs to existing playlist', () => {
const playlists = factory<Playlist>('playlist', 3)
playlistStore.all = playlists
const wrapper = initComponent()
const addSongsStub = mock(playlistStore, 'addSongs')
// @ts-ignore
const closeStub = mock(wrapper.vm, 'close')
wrapper.findAll('li.playlist').at(1).click()
expect(addSongsStub).toHaveBeenCalledWith(playlists[1], songs)
expect(closeStub).toHaveBeenCalled()
})
it('creates new playlist from songs', async () => {
const storeStub = mock(playlistStore, 'store', new Promise(resolve => resolve(factory('playlist'))))
const wrapper = initComponent()
// @ts-ignore
const closeStub = mock(wrapper.vm, 'close')
wrapper.setData({ newPlaylistName: 'Foo' })
wrapper.submit('form.form-new-playlist')
await wrapper.vm.$nextTick()
expect(storeStub).toHaveBeenCalledWith('Foo', songs)
expect(closeStub).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,60 @@
import Component from '@/components/song/card.vue'
import factory from '@/__tests__/factory'
import { queueStore } from '@/stores'
import { playback } from '@/services'
import { mock } from '@/__tests__/__helpers__'
import { Wrapper, shallow } from '@/__tests__/adapter'
import FunctionPropertyNames = jest.FunctionPropertyNames
describe('components/song/card', () => {
let propsData, song: Song, wrapper: Wrapper
beforeEach(() => {
song = factory<Song>('song', {
artist: factory<Artist>('artist', {
id: 42,
name: 'Foo Fighter'
}),
playCount: 10,
playbackState: 'Stopped',
title: 'Foo bar'
})
propsData = {
song,
topPlayCount: 42
}
wrapper = shallow(Component, { propsData })
})
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it.each([[true, false], [false, true]])('queuing and playing behavior', (shouldQueue, queued) => {
const containsStub = mock(queueStore, 'contains', queued)
const queueStub = mock(queueStore, 'queueAfterCurrent')
const playStub = mock(playback, 'play')
wrapper.dblclick('[data-test=song-card]')
expect(containsStub).toHaveBeenCalledWith(song)
if (queued) {
expect(queueStub).not.toHaveBeenCalled()
} else {
expect(queueStub).toHaveBeenCalledWith(song)
}
expect(playStub).toHaveBeenCalledWith(song)
})
it.each<[PlaybackState, FunctionPropertyNames<typeof playback>]>([
['Stopped', 'play'],
['Playing', 'pause'],
['Paused', 'resume']
])('if state is currently "%s", %s', (state, action) => {
const m = mock(playback, action)
song.playbackState = state
wrapper.click('.cover .control')
expect(m).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,97 @@
import Component from '@/components/song/context-menu.vue'
import { download } from '@/services'
import { songStore, playlistStore, queueStore, favoriteStore, sharedStore, userStore } from '@/stores'
import { eventBus } from '@/utils'
import factory from '@/__tests__/factory'
import { mock } from '@/__tests__/__helpers__'
import { mount, Wrapper } from '@/__tests__/adapter'
import FunctionPropertyNames = jest.FunctionPropertyNames
describe('components/song/context-menu', () => {
let songs: Song[], wrapper: Wrapper
beforeEach(() => {
userStore.current.is_admin = true
sharedStore.state.allowDownload = true
songs = factory<Song>('song', 2)
wrapper = mount(Component, {
propsData: { songs },
data: () => ({ copyable: true })
})
})
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('renders properly', async () => {
const selectors = [
'.playback',
'.go-to-album',
'.go-to-artist',
'.after-current',
'.bottom-queue',
'.top-queue',
'.favorite'
]
await (wrapper.vm as any).open(0, 0)
expect(wrapper.hasAll(...selectors)).toBe(true)
})
it.each<[string, string, FunctionPropertyNames<typeof queueStore>]>([
['after current', '.after-current', 'queueAfterCurrent'],
['to bottom', '.bottom-queue', 'queue'],
['to top', '.top-queue', 'queueToTop']
])('queues songs %s when "%s" is clicked', async (to, selector, queueFunc) => {
const m = mock(queueStore, queueFunc)
await (wrapper.vm as any).open(0, 0)
wrapper.click(selector)
expect(m).toHaveBeenCalledWith(songs)
})
it('adds songs to favorite', async () => {
const m = mock(favoriteStore, 'like')
await (wrapper.vm as any).open(0, 0)
wrapper.click('.favorite')
expect(m).toHaveBeenCalledWith(songs)
})
it('adds songs to existing playlist', async () => {
playlistStore.all = factory<Playlist>('playlist', 5)
const m = mock(playlistStore, 'addSongs')
await (wrapper.vm as any).open(0, 0)
const html = wrapper.html()
playlistStore.all.forEach(playlist => expect(html).toMatch(playlist.name))
wrapper.click('.playlist')
expect(m).toHaveBeenCalledWith(playlistStore.all[0], songs)
})
it('opens the edit form', async () => {
const m = mock(eventBus, 'emit')
userStore.current.is_admin = true
await (wrapper.vm as any).open(0, 0)
wrapper.click('.open-edit-form')
expect(m).toHaveBeenCalledWith('MODAL_SHOW_EDIT_SONG_FORM', songs)
})
it('downloads', async () => {
const m = mock(download, 'fromSongs')
await (wrapper.vm as any).open(0, 0)
wrapper.click('.download')
expect(m).toHaveBeenCalledWith(songs)
})
it('copies URL', async () => {
const getShareableUrlMock = mock(songStore, 'getShareableUrl')
const execCommandMock = mock(document, 'execCommand')
const song = factory('song')
await (wrapper.vm as any).open(0, 0)
wrapper.setProps({ songs: [song] })
wrapper.click('.copy-url')
expect(getShareableUrlMock).toHaveBeenCalledWith(song)
expect(execCommandMock).toHaveBeenCalledWith('copy')
})
})

View file

@ -0,0 +1,134 @@
import Component from '@/components/song/edit-form.vue'
import Typeahead from '@/components/ui/typeahead.vue'
import factory from '@/__tests__/factory'
import { songStore } from '@/stores'
import { songInfo } from '@/services/info'
import { mock } from '@/__tests__/__helpers__'
import { mount } from '@/__tests__/adapter'
describe('components/song/edit-form', () => {
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('supports editing a single song', async () => {
const song = factory<Song>('song', { infoRetrieved: true })
const wrapper = mount(Component, {
propsData: { songs: song }
})
await wrapper.vm.$nextTick()
const metaHtml = wrapper.find('.meta').html()
expect(metaHtml).toMatch(song.title)
expect(metaHtml).toMatch(song.album.name)
expect(metaHtml).toMatch(song.artist.name)
await (wrapper.vm as any).open()
expect(wrapper.has(Typeahead)).toBe(true)
expect(wrapper.find('input[name=title]').value).toBe(song.title)
expect(wrapper.find('input[name=album]').value).toBe(song.album.name)
expect(wrapper.find('input[name=artist]').value).toBe(song.artist.name)
expect(wrapper.find('input[name=track]').value).toBe(song.track.toString())
wrapper.click('#editSongTabLyrics')
expect(wrapper.find('textarea[name=lyrics]').value).toBe(song.lyrics)
})
it('fetches song information on demand', () => {
const song = factory('song', { infoRetrieved: false })
const fetchMock = mock(songInfo, 'fetch')
mount(Component, {
propsData: { songs: song }
})
expect(fetchMock).toHaveBeenCalledWith(song)
})
it('supports editing multiple songs of multiple artists', () => {
const wrapper = mount(Component, {
propsData: {
songs: factory('song', 3)
}
})
const metaHtml = wrapper.find('.meta').html()
expect(metaHtml).toMatch('3 songs selected')
expect(metaHtml).toMatch('Mixed Artists')
expect(metaHtml).toMatch('Mixed Albums')
expect(wrapper.find('input[name=artist]').value).toBe('')
expect(wrapper.find('input[name=album]').value).toBe('')
expect(wrapper.has('.tabs .tab-lyrics')).toBe(false)
})
it('supports editing multiple songs of same artist and different albums', () => {
const artist = factory<Artist>('artist')
const wrapper = mount(Component, {
propsData: {
songs: factory('song', 5, {
artist,
artist_id: artist.id
})
}
})
const metaHtml = wrapper.find('.meta').html()
expect(metaHtml).toMatch('5 songs selected')
expect(metaHtml).toMatch(artist.name)
expect(metaHtml).toMatch('Mixed Albums')
expect(wrapper.find('input[name=artist]').value).toBe(artist.name)
expect(wrapper.find('input[name=album]').value).toBe('')
expect(wrapper.has('.tabs .tab-lyrics')).toBe(false)
})
it('supports editing multiple songs in same album', () => {
const album = factory<Album>('album')
const wrapper = mount(Component, {
propsData: {
songs: factory('song', 4, {
album,
album_id: album.id,
artist: album.artist,
artist_id: album.artist.id
})
}
})
const metaHtml = wrapper.find('.meta').html()
expect(metaHtml).toMatch('4 songs selected')
expect(metaHtml).toMatch(album.name)
expect(metaHtml).toMatch(album.artist.name)
expect(wrapper.find('input[name=artist]').value).toBe(album.artist.name)
expect(wrapper.find('input[name=album]').value).toBe(album.name)
expect(wrapper.has('.tabs .tab-lyrics')).toBe(false)
})
it('saves', async () => {
const updateStub = mock(songStore, 'update')
const songs = factory('song', 3)
const formData = { foo: 'bar' }
const wrapper = mount(Component, {
data: () => ({ formData }),
propsData: {
songs
}
})
wrapper.submit('form')
await wrapper.vm.$nextTick()
expect(updateStub).toHaveBeenCalledWith(songs, formData)
})
it('closes', async () => {
const wrapper = mount(Component, {
propsData: {
songs: factory('song', 3)
}
})
await wrapper.vm.$nextTick()
wrapper.click('.btn-cancel')
expect(wrapper.hasEmitted('close')).toBe(true)
})
})

View file

@ -0,0 +1,81 @@
import FunctionPropertyNames = jest.FunctionPropertyNames
import Component from '@/components/song/item.vue'
import factory from '@/__tests__/factory'
import { playback } from '@/services'
import { queueStore } from '@/stores'
import { mock } from '@/__tests__/__helpers__'
import { Wrapper, shallow } from '@/__tests__/adapter'
describe('components/song/item', () => {
let item: SongProxy, song: Song, artist: Artist, album: Album, wrapper: Wrapper
beforeEach(() => {
artist = factory<Artist>('artist')
album = factory<Album>('album', {
artist,
artist_id: artist.id
})
song = factory<Song>('song', {
artist,
album,
artist_id: artist.id,
album_id: album.id,
fmtLength: '04:56'
})
item = { song, selected: false }
wrapper = shallow(Component, { propsData: {
item,
columns: ['track', 'title', 'artist', 'album', 'length']
}})
})
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('renders properly', () => {
const html = wrapper.html()
expect(html).toMatch(song.track.toString())
expect(html).toMatch(song.title)
expect(html).toMatch(artist.name)
expect(html).toMatch(album.name)
expect(html).toMatch('04:56')
})
it('does not render some information if so configured', () => {
wrapper = shallow(Component, { propsData: {
item,
columns: ['track', 'title', 'length']
}})
expect(wrapper.has('.album')).toBe(false)
expect(wrapper.has('.artist')).toBe(false)
})
it.each([[true, false], [false, true]])('queuing and playing behavior', (shouldQueue, queued) => {
const containsStub = mock(queueStore, 'contains', queued)
const queueStub = mock(queueStore, 'queueAfterCurrent')
const playStub = mock(playback, 'play')
wrapper.dblclick('tr')
expect(containsStub).toHaveBeenCalledWith(song)
if (queued) {
expect(queueStub).not.toHaveBeenCalled()
} else {
expect(queueStub).toHaveBeenCalledWith(song)
}
expect(playStub).toHaveBeenCalledWith(song)
})
it.each<[PlaybackState, FunctionPropertyNames<typeof playback>]>([
['Stopped', 'play'],
['Playing', 'pause'],
['Paused', 'resume']
])('if state is currently "%s", %s', (state, action) => {
const m = mock(playback, action)
song.playbackState = state
wrapper.click('.play')
expect(m).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,14 @@
import Component from '@/components/song/like-button.vue'
import { favoriteStore } from '@/stores'
import { mock } from '@/__tests__/__helpers__'
import factory from '@/__tests__/factory'
import { shallow } from '@/__tests__/adapter'
describe('components/song/like-button', () => {
it('toggles', () => {
const m = mock(favoriteStore, 'toggleOne')
const song = factory<Song>('song')
shallow(Component, { propsData: { song }}).click('button')
expect(m).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,62 @@
import Component from '@/components/song/list-controls.vue'
import factory from '@/__tests__/factory'
import { take } from 'lodash'
import { shallow, mount } from '@/__tests__/adapter'
describe('components/song/list-controls', () => {
it('renders properly', () => {
expect(shallow(Component)).toMatchSnapshot()
})
it.each<[Song[], number]>([[factory<Song>('song', 5), 0], [factory<Song>('song', 5), 1]])(
'allows shuffling all if less than 2 songs are selected',
async (songs, selectedSongCount) => {
const selectedSongs = take(songs, selectedSongCount)
const wrapper = mount(Component, { propsData: { songs, selectedSongs } })
await wrapper.vm.$nextTick()
expect(wrapper.click('.btn-shuffle-all').hasEmitted('playAll', true)).toBe(true)
})
it('allows shuffling selected if more than 1 song are selected', () => {
const songs = factory<Song>('song', 5)
expect(shallow(Component, {
propsData: {
songs,
selectedSongs: take(songs, 2)
}
}).click('.btn-shuffle-selected').hasEmitted('playSelected', true)).toBe(true)
})
it('displays the "Add To" menu', () => {
const songs = factory<Song>('song', 5)
expect(shallow(Component, {
propsData: {
songs,
selectedSongs: take(songs, 2)
}
}).has('.btn-add-to')).toBe(true)
})
it('allows clearing queue', () => {
expect(shallow(Component, {
propsData: {
config: {
clearQueue: true
}
}
}).click('.btn-clear-queue').hasEmitted('clearQueue')).toBe(true)
})
it('allows deleting current playlist', () => {
expect(shallow(Component, {
propsData: {
config: {
deletePlaylist: true
}
}
}).click('.btn-delete-playlist').hasEmitted('deletePlaylist')).toBe(true)
})
})

View file

@ -0,0 +1,124 @@
import router from '@/router'
import Component from '@/components/song/list.vue'
import factory from '@/__tests__/factory'
import { queueStore } from '@/stores'
import { playback } from '@/services'
import { mock } from '@/__tests__/__helpers__'
import { mount } from '@/__tests__/adapter'
describe('components/song/list', () => {
let songs: Song[]
beforeEach(() => {
songs = factory<Song>('song', 20)
})
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it.each([
['.track-number', 'song.track'],
['.title', 'song.title'],
['.artist', ['song.album.artist.name', 'song.album.name', 'song.track']],
['.album', ['song.album.name', 'song.track']],
['.time', 'song.length']
])('sorts when "%s" is clicked', (selector, criteria) => {
const wrapper = mount(Component, {
propsData: {
items: songs,
type: 'all-songs'
}
})
// @ts-ignore
const m = mock(wrapper.vm, 'sort')
wrapper.click(`.song-list-header ${selector}`)
expect(m).toHaveBeenCalledWith(criteria)
})
it('takes disc into account when sort an album song list', () => {
const wrapper = mount(Component, {
propsData: {
items: songs,
type: 'album'
}
})
;(wrapper.vm as any).sort()
expect((wrapper.vm as any).sortFields).toContain('song.disc')
})
it('plays when Enter is pressed with one selected song', () => {
const wrapper = mount(Component, {
propsData: {
items: songs,
type: 'all-songs'
}
})
// select one row
;(wrapper.vm as any).songProxies[0].selected = true
const m = mock(playback, 'play')
wrapper.find('.song-list-wrap').trigger('keydown.enter')
expect(m).toHaveBeenCalledWith(songs[0])
})
it('plays when Enter is pressed in Queue screen', () => {
const wrapper = mount(Component, {
propsData: {
items: songs,
type: 'queue'
}
})
const m = mock(playback, 'play')
;(wrapper.vm as any).songProxies[0].selected = true
;(wrapper.vm as any).songProxies[1].selected = true
wrapper.find('.song-list-wrap').trigger('keydown.enter')
expect(m).toHaveBeenCalledWith(songs[0])
})
it('queues when Enter is pressed in other screens', () => {
const wrapper = mount(Component, {
propsData: {
items: songs,
type: 'playlist'
}
})
const queueMock = mock(queueStore, 'queue')
const goMock = mock(router, 'go')
const playMock = mock(playback, 'play')
// select 2 rows
;(wrapper.vm as any).songProxies[0].selected = true
;(wrapper.vm as any).songProxies[1].selected = true
// simple Enter adds selected songs to bottom
wrapper.find('.song-list-wrap').trigger('keydown.enter')
expect(queueMock).toHaveBeenCalledWith((wrapper.vm as any).selectedSongs)
// the current screen should be switched to "Queue"
expect(goMock).toHaveBeenCalledWith('queue')
// Shift+Enter queues to top
const queueToTopMock = mock(queueStore, 'queueToTop')
wrapper.find('.song-list-wrap').trigger('keydown.enter', { shiftKey: true })
expect(queueToTopMock).toHaveBeenCalledWith((wrapper.vm as any).selectedSongs)
expect(goMock).toHaveBeenCalledWith('queue')
// Ctrl[+Shift]+Enter queues and plays the first song
wrapper.find('.song-list-wrap').trigger('keydown.enter', { ctrlKey: true })
expect(playMock).toHaveBeenCalledWith((wrapper.vm as any).selectedSongs[0])
})
it('selects all songs', () => {
const wrapper = mount(Component, {
propsData: {
items: songs,
type: 'playlist'
}
})
wrapper.find('.song-list-wrap').trigger('keydown.a', { ctrlKey: true })
;(wrapper.vm as any).songProxies.forEach((item: SongProxy) => expect(item.selected).toBe(true))
})
})

View file

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/ui/album-art-overlay requests album thumbnail 1`] = `<div data-testid="album-art-overlay" style="background-image: none;"></div>`;

View file

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/ui/context-menu renders 1`] = ``;

View file

@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/ui/lyrics displays a fallback message if the song has no lyrics 1`] = `
<article id="lyrics">
<div class="content">
<div style="display: none;">
<div></div>
<textzoomer-stub></textzoomer-stub>
</div>
<p class="none text-secondary"><span>No lyrics available. Are you listening to Bach?</span></p>
</div>
</article>
`;
exports[`components/ui/lyrics displays lyrics if the song has lyrics 1`] = `
<article id="lyrics">
<div class="content">
<div>
<div>Foo and bar</div>
<textzoomer-stub></textzoomer-stub>
</div>
<!---->
</div>
</article>
`;

View file

@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/shared/overlay allows option overriding 1`] = `
<div id="overlay" class="overlay warning">
<div class="display">
<!---->
<!----> <i class="fa fa-exclamation-triangle"></i>
<!---->
<!----> <span class="message">Foo</span></div> <button class="btn-dismiss">Close</button>
</div>
`;
exports[`components/shared/overlay hides 1`] = ``;
exports[`components/shared/overlay shows 1`] = `
<div id="overlay" class="overlay loading">
<div class="display">
<div data-test="soundbars" class="bars"><img src="@/../img/bars.gif" alt="Sound bars" height="13" width="auto"></div>
<!---->
<!---->
<!---->
<!----> <span class="message"></span>
</div>
<!---->
</div>
`;
exports[`components/shared/overlay shows with default options 1`] = `
<div id="overlay" class="overlay loading">
<div class="display">
<div data-test="soundbars" class="bars"><img src="@/../img/bars.gif" alt="Sound bars" height="13" width="auto"></div>
<!---->
<!---->
<!---->
<!----> <span class="message"></span>
</div>
<!---->
</div>
`;

View file

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/song/list-controls-toggler renders properly 1`] = `<span class="controls-toggler text-orange"><i class="fa fa-angle-up toggler"></i></span>`;

View file

@ -0,0 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/ui/to-top-button renders properly 1`] = `
<div class="to-top-btn-wrapper" style="display: none;" name="fade"><button title="Scroll to top"><i class="fa fa-arrow-circle-up"></i> Top
</button></div>
`;

View file

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/ui/volume renders properly 1`] = `<span id="volume" class="volume control"><i role="button" tabindex="0" title="Mute" class="fa fa-volume-up mute"></i> <input id="volumeRange" max="10" step="0.1" title="Volume" type="range" class="plyr__volume"></span>`;

View file

@ -0,0 +1,24 @@
import factory from '@/__tests__/factory'
import Component from '@/components/ui/album-art-overlay.vue'
import { albumStore } from '@/stores/album'
import { shallow } from '@/__tests__/adapter'
import { mock } from '@/__tests__/__helpers__'
import { preferenceStore } from '@/stores'
describe('components/ui/album-art-overlay', () => {
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('requests album thumbnail', async () => {
preferenceStore.state.showAlbumArtOverlay = true
const getCoverThumbnailMock = mock(albumStore, 'getThumbnail').mockResolvedValue('http://localhost/foo_thumb.jpg')
const song = factory<Song>('song')
const wrapper = shallow(Component)
wrapper.setProps({ song })
expect(getCoverThumbnailMock).toHaveBeenCalledWith(song.album)
expect(wrapper).toMatchSnapshot()
})
})

View file

@ -0,0 +1,70 @@
import Component from '@/components/ui/album-artist-thumbnail.vue'
import factory from '@/__tests__/factory'
import { playback } from '@/services'
import { queueStore, sharedStore } from '@/stores'
import { mock } from '@/__tests__/__helpers__'
import { Wrapper, shallow } from '@/__tests__/adapter'
describe('components/ui/album-artist-thumbnail(album)', () => {
let album: Album
let wrapper: Wrapper
beforeEach(() => {
album = factory<Album>('album', {
songs: factory<Song>('song', 10)
})
// @ts-ignore
sharedStore.state = { allowDownload: true }
wrapper = shallow(Component, { propsData: { entity: album } })
})
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('plays if clicked', () => {
const m = mock(playback, 'playAllInAlbum')
wrapper.click('.control-play')
expect(m).toHaveBeenCalledWith(album, false)
})
it.each([['metaKey'], ['ctrlKey']])('queues if %s is clicked', key => {
const m = mock(queueStore, 'queue')
wrapper.click('.control-play', { [key]: true })
expect(m).toHaveBeenCalled()
})
})
describe('components/ui/album-artist-thumbnail(artist)', () => {
let artist: Artist
let wrapper: Wrapper
beforeEach(() => {
// @ts-ignore
sharedStore.state = { allowDownload: true }
artist = factory<Artist>('artist', {
id: 3, // make sure it's not "Various Artists"
albums: factory<Album>('album', 4),
songs: factory<Song>('song', 16)
})
wrapper = shallow(Component, { propsData: { entity: artist } })
})
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('plays if clicked', () => {
const m = mock(playback, 'playAllByArtist')
wrapper.click('.control-play')
expect(m).toHaveBeenCalledWith(artist, false)
})
it.each([['metaKey'], ['ctrlKey']])('queues if %s is clicked', key => {
const m = mock(queueStore, 'queue')
wrapper.click('.control-play', { [key]: true })
expect(m).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,10 @@
import Component from '@/components/ui/close-modal-btn.vue'
import { mount } from '@/__tests__/adapter'
describe('components/ui/close-modal-btn', () => {
it('emits a click event', () => {
const wrapper = mount(Component)
wrapper.click('button')
expect(wrapper.hasEmitted('click'))
})
})

View file

@ -0,0 +1,42 @@
import Component from '@/components/ui/context-menu.vue'
import { eventBus } from '@/utils'
import { mock } from '@/__tests__/__helpers__'
import { mount } from '@/__tests__/adapter'
declare const global: any
describe('components/ui/context-menu', () => {
afterEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('renders', () => {
expect(mount(Component)).toMatchSnapshot()
})
it('renders extra CSS classes', async () => {
const wrapper = mount(Component, {
propsData: {
extraClass: 'foo'
}
})
await (wrapper.vm as any).open(0, 0)
expect(wrapper.find('.menu').hasClass('foo')).toBe(true)
})
it('opens', () => {
const wrapper = mount(Component)
;(wrapper.vm as any).open(42, 128)
expect(wrapper.find('.menu').element.style.top).toBe('42px')
expect(wrapper.find('.menu').element.style.left).toBe('128px')
expect(global.getComputedStyle(wrapper.find('.menu').element).display).toBe('block')
})
it('closes', async () => {
const wrapper = mount(Component)
await (wrapper.vm as any).open(42, 128)
;(wrapper.vm as any).close()
expect(wrapper.html()).toBeUndefined()
})
})

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