Merge pull request #12 from sundowndev/release/v2

Version 2


    Remove useless getters in questions store
    Create a dedicated page and component for score screen
    Create Quiz component for home page
    Fix some type declarations
    Move CSS style to the global scope
    Testing Score screen component
    Testing Score screen view
    Testing Quiz component
    Testing Home view
refactor/jest-config
Raphaël 2019-12-07 19:44:08 +01:00 committed by GitHub
commit a17622aa1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 503 additions and 224 deletions

17
client/public/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>Hetic VS. EEMI</title>
</head>
<body>
<noscript>
<strong>We're sorry but this website doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

2
client/public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow:

View File

@ -24,4 +24,19 @@
}
}
}
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,53 @@
<template>
<div id="quizz_input">
<h2 id="question_text">{{ currentQuestion.text }}</h2>
<p>
Plus Éemien ou Héticien ?
<span class="progress">({{ index + 1 }}/{{ questions.length }})</span>
</p>
<span>
<button v-for="(choice) in schools" :key="choice.id" class="choice-btn" @click="answer(choice.id)">{{ choice.name }}</button>
</span>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { mapState, mapGetters, mapMutations } from 'vuex';
import router from '../router';
import IQuestion from '../models/question';
export default Vue.extend({
name: 'Quiz',
computed: {
...mapState('questions', [
'questions',
'index',
'score',
]),
...mapGetters('questions', [
'currentQuestion',
'checkAnswer',
'isLastQuestion',
]),
...mapState('schools', ['schools']),
},
methods: {
answer(answer: number): void {
const index: number = this.index;
const questions: IQuestion[] = this.questions;
if (this.checkAnswer({ answer })) {
this.$store.dispatch('questions/increaseScore');
}
if (!this.isLastQuestion) {
this.$store.dispatch('questions/increaseIndex');
} else {
router.push({ name: 'scoreScreen' });
}
},
},
});
</script>

View File

@ -1,18 +1,23 @@
<template>
<div id="score_screen">
<h2>Score: {{ score }}/{{ questions.length }}</h2>
<h2>Score: {{ score }}/{{ count }}</h2>
<h3>{{ text() }}</h3>
<button class="replay-btn" @click="reset">Recommencer</button>
<button class="replay-btn" @click="resetGame">Recommencer</button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { mapState, mapGetters, mapMutations } from 'vuex';
import router from '../router';
interface IMessage {
min: number;
msg: string;
}
export default Vue.extend({
name: 'ScoreScreen',
data() {
data(): { messages: IMessage[] } {
return {
messages: [
{ min: 0, msg: 'T\'as pas lu les questions avoues.' },
@ -23,37 +28,22 @@ export default Vue.extend({
};
},
props: {
reset: Function,
},
computed: {
...mapGetters('questions', ['questions', 'score']),
score: Number,
count: Number,
},
methods: {
text() {
text(): string {
const text = this.messages.filter((msg) => this.score >= msg.min);
return text[0] !== undefined ? text[0].msg : '';
},
resetGame(): void {
this.$store.dispatch('questions/resetState');
router.push({ name: 'home' });
},
},
created() {
created(): void {
this.messages.sort((a, b) => b.min - a.min);
},
});
</script>
<style scoped lang="scss">
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>
</script>

View File

@ -1,6 +1,7 @@
import Vue from 'vue';
import Router from 'vue-router';
import Home from './views/Home.vue';
import ScoreScreen from './views/ScoreScreen.vue';
Vue.use(Router);
@ -13,5 +14,14 @@ export default new Router({
name: 'home',
component: Home,
},
{
path: '/end',
name: 'scoreScreen',
component: ScoreScreen,
},
{
path: '*',
redirect: { name: 'home' },
},
],
});

View File

@ -21,15 +21,6 @@ IStoreQuestions,
questions: [],
},
getters: {
index(state): number {
return state.index;
},
score(state): number {
return state.score;
},
questions(state): IQuestion[] {
return state.questions;
},
currentQuestion(state): IQuestion | {} {
return state.questions.length ? state.questions[state.index] : {};
},

View File

@ -13,11 +13,7 @@ const schools: Module<IStoreSchools, any> = {
state: {
schools: [],
},
getters: {
schools(state): ISchool[] {
return state.schools;
},
},
getters: {},
mutations: {
setSchools(state, payload): ISchool[] {
return (state.schools = payload);

View File

@ -1,68 +1,17 @@
<template>
<div class="home">
<div id="quizz_input" v-show="!finished">
<h2 id="question_text">{{ currentQuestion.text }}</h2>
<p>
Plus Éemien ou Héticien ?
<span class="progress">({{ index + 1 }}/{{ questions.length }})</span>
</p>
<span v-for="(choice) in schools" :key="choice.id">
<button class="choice-btn" @click="answer(choice.id)">{{ choice.name }}</button>
</span>
</div>
<ScoreScreen v-show="finished" :reset="resetGame" />
<Quiz />
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import IQuestion from '../models/question';
import { mapState, mapGetters, mapMutations } from 'vuex';
import ScoreScreen from '../components/ScoreScreen.vue';
import Quiz from '../components/Quiz.vue';
export default Vue.extend({
name: 'home',
data() {
return {
finished: false,
};
},
computed: {
...mapGetters('schools', ['schools']),
...mapGetters('questions', [
'questions',
'index',
'score',
'currentQuestion',
'checkAnswer',
'isLastQuestion',
]),
},
methods: {
answer(answer: number): void {
const index: number = this.index;
const questions: IQuestion[] = this.questions;
if (this.checkAnswer({ answer })) {
this.$store.dispatch('questions/increaseScore');
}
if (!this.isLastQuestion) {
this.$store.dispatch('questions/increaseIndex');
} else {
this.finished = true;
}
},
resetGame(): void {
this.finished = false;
this.$store.dispatch('questions/resetState');
},
},
components: {
ScoreScreen,
},
components: { Quiz },
async created() {
await this.$store.dispatch('questions/fetchQuestions');
await this.$store.dispatch('schools/fetchSchools');

View File

@ -0,0 +1,26 @@
<template>
<ScoreScreen :score="score" :count="questions.length" />
</template>
<script lang="ts">
import Vue from 'vue';
import IQuestion from '../models/question';
import { mapState, mapGetters, mapMutations } from 'vuex';
import ScoreScreen from '../components/ScoreScreen.vue';
import router from '../router';
export default Vue.extend({
name: 'home',
components: {
ScoreScreen,
},
computed: {
...mapState('questions', ['questions', 'index', 'score']),
},
created(): void {
if (this.index === 0) {
router.push({ name: 'home' });
}
},
});
</script>

View File

@ -1,90 +1,132 @@
describe("Home page", () => {
it("should have content displayed", () => {
cy.server({ status: 200 });
cy.route("/schools", {
describe('Home page', () => {
describe('content', () => {
it('should display quizz component', () => {
cy.get('#quizz_input').should('be.visible');
});
it('should display title', () => {
cy.get('h1#title').should('be.visible');
cy.contains('h1#title', 'HETIC vs EEMI');
});
it('should display progress', () => {
cy.get('span.progress').should('be.visible');
cy.contains('span.progress', '1/2');
});
it('should display choice text', () => {
cy.get('p').should('be.visible');
cy.contains('p', 'Plus Éemien ou Héticien ?');
});
it('should display question', () => {
cy.server();
cy.route('/questions', {
questions: [{ text: 'test_cypress', answer: 2 }],
});
cy.get('h2#question_text').should('be.visible');
cy.contains('h2#question_text', 'test_cypress');
});
it('should display schools', () => {
cy.server();
cy.route('/schools', {
schools: [
{ id: 1, name: 'school1' },
{ id: 2, name: 'school2' },
{ id: 3, name: 'school3' },
],
});
cy.get('.choice-btn').should('be.visible');
cy.get('.choice-btn').should('have.length', 2);
cy.get('.choice-btn')
.first()
.should('have.text', 'school1');
});
});
it('should finish game with score 0', () => {
cy.server();
cy.route('/schools', {
schools: [
{ id: 1, name: "school1" },
{ id: 2, name: "school2" },
{ id: 3, name: "school3" }
]
{ id: 1, name: 'school1' },
{ id: 2, name: 'school2' },
],
});
cy.route("/questions", {
questions: [{ text: "test_cypress", answer: 2 }]
cy.route('/questions', {
questions: [
{ text: 'test_cypress', answer: 2 },
{ text: 'test_cypress', answer: 2 },
],
});
cy.visit("/");
cy.visit('/');
// Elements are visible
cy.get("h1#title").should("be.visible");
cy.get("h2#question_text").should("be.visible");
cy.get("span.progress").should("be.visible");
cy.get("p").should("be.visible");
cy.get(".choice-btn").should("be.visible");
cy.get("#quizz_input").should("be.visible");
cy.get("#score_screen").should("not.be.visible");
cy.contains('#question_text', 'test_cypress');
// Elements contain right content
cy.contains("#title", "HETIC vs EEMI");
cy.get("h2#question_text").should("not.be.empty");
cy.contains("p", "Plus Éemien ou Héticien ?");
cy.get(".choice-btn").should("have.length", 3);
cy.get(".choice-btn")
.first()
.should("have.text", "school1");
});
it("should finish game with score 0", () => {
cy.server({ status: 200 });
cy.route("/schools", {
schools: [{ id: 1, name: "school1" }, { id: 2, name: "school2" }]
});
cy.route("/questions", {
questions: [{ text: "test_cypress", answer: 2 }]
});
cy.visit("/");
cy.contains("#question_text", "test_cypress");
cy.get(".choice-btn")
cy.get('.choice-btn')
.first()
.click();
cy.contains("Score: 0/1");
cy.get('.choice-btn')
.first()
.click();
cy.location().should((loc) => {
expect(loc.pathname).to.eq('/end');
});
cy.contains('Score: 0/2');
cy.contains("T'as pas lu les questions avoues.");
cy.contains("Recommencer");
cy.contains('Recommencer');
cy.get("#quizz_input").should("not.be.visible"); // Quizz is hidden
cy.get("#score_screen").should("be.visible"); // Score screen is displayed
cy.get('#quizz_input').should('not.be.visible'); // Quizz is hidden
cy.get('#score_screen').should('be.visible'); // Score screen is displayed
});
it("should finish game with score 1 and retry", () => {
cy.server({ status: 200 });
cy.route("/schools", {
schools: [{ id: 1, name: "school1" }, { id: 2, name: "school2" }]
it('should finish game with score 1 and retry', () => {
cy.server();
cy.route('/schools', {
schools: [
{ id: 1, name: 'school1' },
{ id: 2, name: 'school2' },
],
});
cy.route("/questions", {
questions: [{ text: "test_cypress", answer: 1 }]
cy.route('/questions', {
questions: [
{ text: 'test_cypress', answer: 1 },
{ text: 'test_cypress', answer: 1 },
],
});
cy.visit("/");
cy.visit('/');
cy.contains("#question_text", "test_cypress");
cy.contains('#question_text', 'test_cypress');
cy.get(".choice-btn")
cy.get('.choice-btn')
.first()
.click();
cy.contains("Score: 1/1");
cy.get('.choice-btn')
.first()
.click();
cy.location().should((loc) => {
expect(loc.pathname).to.eq('/end');
});
cy.contains('Score: 2/2');
cy.contains("Bon bah c'est pas tip top tout ça.");
cy.contains("Recommencer");
cy.contains('Recommencer');
cy.get("#quizz_input").should("not.be.visible"); // Quizz is hidden
cy.get("#score_screen").should("be.visible"); // Score screen is displayed
cy.get('#quizz_input').should('not.be.visible'); // Quizz is hidden
cy.get('#score_screen').should('be.visible'); // Score screen is displayed
cy.get(".replay-btn").click();
cy.get('.replay-btn').click();
cy.get("#quizz_input").should("be.visible"); // Quizz is visible
cy.get("#score_screen").should("not.be.visible"); // Score screen is hidden
cy.get('#quizz_input').should('be.visible'); // Quizz is visible
cy.get('#score_screen').should('not.be.visible'); // Score screen is hidden
});
});

View File

@ -0,0 +1,124 @@
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import Quiz from '../../../src/components/Quiz.vue';
import router from '@/router';
const localVue = createLocalVue();
localVue.use(Vuex);
const checkAnswerMock = jest.fn();
let isLastQuestionMock = false;
let $store: any;
let wrapper: any;
describe('Component - Quiz', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
$store = new Vuex.Store({
modules: {
questions: {
namespaced: true,
state: {
index: 0,
score: 0,
questions: [{ answer: 1, text: 'test' }],
},
getters: {
currentQuestion: () => ({
text: 'currentQuestion',
}),
checkAnswer: () => checkAnswerMock,
isLastQuestion: () => isLastQuestionMock,
},
},
schools: {
namespaced: true,
state: {
schools: [{ id: 1, name: 'school1' }],
},
getters: {},
},
},
});
wrapper = shallowMount(Quiz, {
mocks: { $store },
});
});
it('should display question', () => {
expect(wrapper.find('#question_text').text()).toBe('currentQuestion');
});
it('should display question', () => {
expect(wrapper.find('span.progress').text()).toBe('(1/1)');
});
it('should only increase index', () => {
const dispatchMock = spyOn($store, 'dispatch');
const routerMock = spyOn(router, 'push');
checkAnswerMock.mockImplementation(() => false);
isLastQuestionMock = false;
wrapper
.findAll('.choice-btn')
.at(0)
.trigger('click');
expect(checkAnswerMock).toBeCalledTimes(1);
expect(checkAnswerMock).toBeCalledWith({ answer: 1 });
expect(dispatchMock).toBeCalledTimes(1);
expect(dispatchMock).toBeCalledWith('questions/increaseIndex');
expect(routerMock).toBeCalledTimes(0);
});
it('should increase score and index', () => {
const dispatchMock = spyOn($store, 'dispatch');
const routerMock = spyOn(router, 'push');
checkAnswerMock.mockImplementation(() => true);
isLastQuestionMock = false;
wrapper
.findAll('.choice-btn')
.at(0)
.trigger('click');
expect(checkAnswerMock).toBeCalledTimes(1);
expect(checkAnswerMock).toBeCalledWith({ answer: 1 });
expect(dispatchMock).toBeCalledTimes(2);
expect(dispatchMock).nthCalledWith(1, 'questions/increaseScore');
expect(dispatchMock).nthCalledWith(2, 'questions/increaseIndex');
expect(routerMock).toBeCalledTimes(0);
});
it('should to score screen', () => {
const dispatchMock = spyOn($store, 'dispatch');
const routerMock = spyOn(router, 'push');
checkAnswerMock.mockImplementation(() => false);
isLastQuestionMock = true;
wrapper
.findAll('.choice-btn')
.at(0)
.trigger('click');
expect(checkAnswerMock).toBeCalledTimes(1);
expect(checkAnswerMock).toBeCalledWith({ answer: 1 });
expect(dispatchMock).toBeCalledTimes(0);
expect(routerMock).toBeCalledTimes(1);
expect(routerMock).toBeCalledWith({ name: 'scoreScreen' });
});
});

View File

@ -0,0 +1,55 @@
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import ScoreScreen from '../../../src/components/ScoreScreen.vue';
import router from '@/router';
const localVue = createLocalVue();
localVue.use(Vuex);
let $store: any;
let wrapper: any;
describe('Component - ScoreScreen', () => {
beforeEach(() => {
$store = new Vuex.Store({
modules: {
questions: {
namespaced: true,
state: {
index: 0,
score: 0,
questions: [],
},
},
},
});
wrapper = shallowMount(ScoreScreen, {
mocks: { $store },
propsData: {
score: 2,
count: 5,
},
});
});
it('should display message based on score', () => {
expect(wrapper.find('h3').text()).toBe(
'Bon bah c\'est pas tip top tout ça.',
);
});
it('should call reset state function', () => {
const dispatchMock = spyOn($store, 'dispatch');
const routerMock = spyOn(router, 'push');
wrapper.find('button.replay-btn').trigger('click');
expect(dispatchMock).toBeCalledTimes(1);
expect(dispatchMock).toBeCalledWith('questions/resetState');
expect(routerMock).toBeCalledTimes(1);
expect(routerMock).toBeCalledWith({ name: 'home' });
});
});

View File

@ -21,36 +21,6 @@ describe('Store - Questions', () => {
});
describe('Getters', () => {
describe('#index', () => {
it('should get index', () => {
state.index = 5;
const index = getters.index(state, null, null, null);
expect(index).toEqual(5);
});
});
describe('#score', () => {
it('should get score', () => {
state.score = 5;
const score = getters.score(state, null, null, null);
expect(score).toEqual(5);
});
});
describe('#questions', () => {
it('should get questions', () => {
state.questions = [{ text: 'test', answer: 1 }];
const data = getters.questions(state, null, null, null);
expect(data).toStrictEqual(state.questions);
});
});
describe('#currentQuestion', () => {
it('should get current question', () => {
state.index = 1;
@ -77,7 +47,12 @@ describe('Store - Questions', () => {
it('should check answer and get true', () => {
state.questions = [{ text: 'test', answer: 1 }];
const isCorrect = getters.checkAnswer(state, null, null, null)({
const isCorrect = getters.checkAnswer(
state,
null,
null,
null,
)({
answer: 1,
});
@ -87,7 +62,12 @@ describe('Store - Questions', () => {
it('should check answer and get false', () => {
state.questions = [{ text: 'test', answer: 2 }];
const isCorrect = getters.checkAnswer(state, null, null, null)({
const isCorrect = getters.checkAnswer(
state,
null,
null,
null,
)({
answer: 1,
});

View File

@ -16,19 +16,6 @@ describe('Store - Questions', () => {
};
});
describe('Getters', () => {
describe('#schools', () => {
it('should get schools', () => {
state.schools = [{ id: '1', name: 'test' }, { id: '2', name: 'test2' }];
const data = getters.schools(state, null, null, null);
expect(data).toStrictEqual(state.schools);
expect(data.length).toBe(2);
});
});
});
describe('Mutations', () => {
describe('#setSchools', () => {
it('should set schools state', () => {

View File

@ -4,7 +4,7 @@ import Home from '../../../src/views/Home.vue';
import axios from 'axios';
import config from '@/config';
import questions from '@/store/modules/questions';
import schools from '@/store/modules/questions';
import schools from '@/store/modules/schools';
const localVue = createLocalVue();
@ -18,17 +18,13 @@ const $store = new Vuex.Store({
});
const axiosMock = jest
.spyOn(axios, 'get')
.mockResolvedValue({ data: { questions: [] } } as any);
const wrapper = shallowMount(Home, { mocks: { $store } });
.mockResolvedValueOnce({ data: { questions: [] } } as any)
.mockResolvedValueOnce({ data: { schools: [] } } as any);
describe.skip('Views - Home', () => {
it('should ', () => {
const storeDispatchMock = spyOn($store, 'dispatch');
shallowMount(Home, { mocks: { $store } });
// wrapper.find('.todoList__removeDone').trigger('click');
// expect(storeDispatchMock).toBeCalledTimes(2);
// expect(storeDispatchMock).toHaveBeenNthCalledWith(1, 'fetchQuestions');
// expect(storeDispatchMock).toHaveBeenNthCalledWith(2, 'fetchSchools');
describe('Views - Home', () => {
it('should fetch questions and shools', () => {
expect(axiosMock).toBeCalledTimes(2);
expect(axiosMock).toHaveBeenNthCalledWith(1, `${config.apiUrl}/questions`);
expect(axiosMock).toHaveBeenNthCalledWith(2, `${config.apiUrl}/schools`);

View File

@ -0,0 +1,53 @@
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import ScoreScreen from '../../../src/views/ScoreScreen.vue';
import router from '@/router';
const localVue = createLocalVue();
localVue.use(Vuex);
let wrapper: any;
const mountIt = (index = 0, score = 0, questions = []) => {
wrapper = shallowMount(ScoreScreen, {
mocks: {
$store: new Vuex.Store({
modules: {
questions: {
namespaced: true,
state: {
index,
score,
questions,
},
},
},
}),
},
});
};
describe('Views - ScoreScreen', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
});
it('should redirect to home on index 0', () => {
const routerMock = spyOn(router, 'push');
mountIt();
expect(routerMock).toBeCalledTimes(1);
expect(routerMock).toBeCalledWith({ name: 'home' });
});
it('should not redirect to home', () => {
mountIt(1);
const routerMock = spyOn(router, 'push');
expect(routerMock).toBeCalledTimes(0);
});
});

2
package-lock.json generated
View File

@ -1,5 +1,5 @@
{
"name": "HETICvsEEMI",
"name": "hetic-vs-eemi",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,

View File

@ -1,5 +1,5 @@
{
"name": "HETICvsEEMI",
"name": "hetic-vs-eemi",
"version": "1.0.0",
"description": "Plus Eemien ou Héticien ? Le jeu qui ne fait pas rire les élèves.",
"main": "index.js",
@ -16,8 +16,6 @@
"type": "git",
"url": "git+https://github.com/sundowndev/HETICvsEEMI.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/sundowndev/HETICvsEEMI/issues"

View File

@ -2,7 +2,6 @@
"name": "server",
"private": false,
"version": "1.0.0",
"description": "",
"main": "index.js",
"dependencies": {
"compression": "^1.7.4",
@ -10,11 +9,7 @@
"express": "^4.17.1",
"morgan": "^1.9.1"
},
"devDependencies": {},
"scripts": {
"start": "node index.js"
},
"keywords": [],
"author": "",
"license": "ISC"
}
}