Skip to content

Commit

Permalink
feat: allow checking backwards strokes (#252)
Browse files Browse the repository at this point in the history
* feat: allow checking backwards strokes

* fixup: fix getMatchData types

Because it is recursive, it requires an explicit return type annotation.

* fixup: add single point test case

* fixup: avoid ternary values
  • Loading branch information
matt-tingen authored Oct 2, 2021
1 parent f1a810c commit 15d37e8
Show file tree
Hide file tree
Showing 6 changed files with 277 additions and 43 deletions.
33 changes: 23 additions & 10 deletions src/Quiz.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import strokeMatches from './strokeMatches';
import strokeMatches, { StrokeMatchResultMeta } from './strokeMatches';
import UserStroke from './models/UserStroke';
import Positioner from './Positioner';
import { counter, colorStringToVals } from './utils';
Expand Down Expand Up @@ -92,8 +92,10 @@ export default class Quiz {
return;
}

const { acceptBackwardsStrokes } = this._options!;

const currentStroke = this._getCurrentStroke();
const isMatch = strokeMatches(
const { isMatch, meta } = strokeMatches(
this._userStroke,
this._character,
this._currentStrokeIndex,
Expand All @@ -103,10 +105,12 @@ export default class Quiz {
},
);

if (isMatch) {
this._handleSuccess();
const isAccepted = isMatch || (meta.isStrokeBackwards && acceptBackwardsStrokes);

if (isAccepted) {
this._handleSuccess(meta);
} else {
this._handleFailure();
this._handleFailure(meta);

const {
showHintAfterMisses,
Expand Down Expand Up @@ -143,7 +147,13 @@ export default class Quiz {
}
}

_getStrokeData(isCorrect = false): StrokeData {
_getStrokeData({
isCorrect,
meta,
}: {
isCorrect: boolean;
meta: StrokeMatchResultMeta;
}): StrokeData {
return {
character: this._character.symbol,
strokeNum: this._currentStrokeIndex,
Expand All @@ -152,10 +162,11 @@ export default class Quiz {
strokesRemaining:
this._character.strokes.length - this._currentStrokeIndex - (isCorrect ? 1 : 0),
drawnPath: getDrawnPath(this._userStroke!),
isBackwards: meta.isStrokeBackwards,
};
}

_handleSuccess() {
_handleSuccess(meta: StrokeMatchResultMeta) {
if (!this._options) return;

const { strokes, symbol } = this._character;
Expand All @@ -168,7 +179,9 @@ export default class Quiz {
strokeHighlightDuration,
} = this._options;

onCorrectStroke?.(this._getStrokeData(true));
onCorrectStroke?.({
...this._getStrokeData({ isCorrect: true, meta }),
});

let animation: MutationChain = characterActions.showStroke(
'main',
Expand Down Expand Up @@ -200,10 +213,10 @@ export default class Quiz {
this._renderState.run(animation);
}

_handleFailure() {
_handleFailure(meta: StrokeMatchResultMeta) {
this._mistakesOnStroke += 1;
this._totalMistakes += 1;
this._options!.onMistake?.(this._getStrokeData());
this._options!.onMistake?.(this._getStrokeData({ isCorrect: false, meta }));
}

_getCurrentStroke() {
Expand Down
180 changes: 171 additions & 9 deletions src/__tests__/Quiz-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,10 @@ describe('Quiz', () => {

describe('endUserStroke', () => {
it('finishes the stroke and moves on if it was correct', async () => {
(strokeMatches as any).mockImplementation(() => true);
(strokeMatches as any).mockImplementation(() => ({
isMatch: true,
meta: { isStrokeBackwards: false },
}));

const renderState = createRenderState();
const quiz = new Quiz(
Expand Down Expand Up @@ -318,6 +321,7 @@ describe('Quiz', () => {
{ x: 105, y: 205 },
],
},
isBackwards: false,
});
expect(onMistake).not.toHaveBeenCalled();
expect(onComplete).not.toHaveBeenCalled();
Expand All @@ -331,8 +335,141 @@ describe('Quiz', () => {
expect(renderState.state.userStrokes![currentStrokeId]).toBe(null);
});

it('accepts backwards stroke when allowed', async () => {
(strokeMatches as any).mockImplementation(() => ({
isMatch: false,
meta: { isStrokeBackwards: true },
}));

const renderState = createRenderState();
const quiz = new Quiz(
char,
renderState,
new Positioner({ padding: 20, width: 200, height: 200 }),
);
const onCorrectStroke = jest.fn();
const onMistake = jest.fn();
const onComplete = jest.fn();
quiz.startQuiz(
Object.assign({}, opts, {
onCorrectStroke,
onComplete,
onMistake,
acceptBackwardsStrokes: true,
}),
);
clock.tick(1000);
await resolvePromises();

quiz.startUserStroke({ x: 100, y: 200 });
quiz.continueUserStroke({ x: 10, y: 20 });

const currentStrokeId = quiz._userStroke!.id;
expect(quiz._currentStrokeIndex).toBe(0);
quiz.endUserStroke();
await resolvePromises();

expect(quiz._userStroke).toBeUndefined();
expect(quiz._isActive).toBe(true);
expect(quiz._currentStrokeIndex).toBe(1);
expect(onCorrectStroke).toHaveBeenCalledTimes(1);
expect(onCorrectStroke).toHaveBeenCalledWith({
character: '人',
mistakesOnStroke: 0,
strokeNum: 0,
strokesRemaining: 1,
totalMistakes: 0,
drawnPath: {
pathString: 'M 100 200 L 10 20',
points: [
{ x: 105, y: 205 },
{ x: 15, y: 25 },
],
},
isBackwards: true,
});
expect(onMistake).not.toHaveBeenCalled();
expect(onComplete).not.toHaveBeenCalled();

clock.tick(1000);
await resolvePromises();

expect(renderState.state.character.main.strokes[0].opacity).toBe(1);
expect(renderState.state.character.main.strokes[1].opacity).toBe(0);
// should fade and disappear
expect(renderState.state.userStrokes![currentStrokeId]).toBe(null);
});

it('notes backwards stroke when checking', async () => {
(strokeMatches as any).mockImplementation(() => ({
isMatch: false,
meta: { isStrokeBackwards: true },
}));

const renderState = createRenderState();
const quiz = new Quiz(
char,
renderState,
new Positioner({ padding: 20, width: 200, height: 200 }),
);
const onCorrectStroke = jest.fn();
const onMistake = jest.fn();
const onComplete = jest.fn();
quiz.startQuiz(
Object.assign({}, opts, {
onCorrectStroke,
onComplete,
onMistake,
acceptBackwardsStrokes: false,
}),
);
clock.tick(1000);
await resolvePromises();

quiz.startUserStroke({ x: 100, y: 200 });
quiz.continueUserStroke({ x: 10, y: 20 });

const currentStrokeId = quiz._userStroke!.id;
expect(quiz._currentStrokeIndex).toBe(0);
quiz.endUserStroke();
await resolvePromises();

expect(quiz._userStroke).toBeUndefined();
expect(quiz._isActive).toBe(true);
expect(quiz._currentStrokeIndex).toBe(0);
expect(onMistake).toHaveBeenCalledTimes(1);
expect(onMistake).toHaveBeenCalledWith({
character: '人',
mistakesOnStroke: 1,
strokeNum: 0,
strokesRemaining: 2,
totalMistakes: 1,
drawnPath: {
pathString: 'M 100 200 L 10 20',
points: [
{ x: 105, y: 205 },
{ x: 15, y: 25 },
],
},
isBackwards: true,
});
expect(onCorrectStroke).not.toHaveBeenCalled();
expect(onComplete).not.toHaveBeenCalled();

clock.tick(1000);
await resolvePromises();

expect(renderState.state.character.main.strokes[0].opacity).toBe(0);
expect(renderState.state.character.main.strokes[1].opacity).toBe(0);
// should fade and disappear
expect(renderState.state.userStrokes![currentStrokeId]).toBe(null);
});

it('ignores single point strokes', async () => {
(strokeMatches as any).mockImplementation(() => false);
(strokeMatches as any).mockImplementation(() => ({
isMatch: false,
meta: { isStrokeBackwards: false },
}));

const renderState = createRenderState();
const quiz = new Quiz(
Expand Down Expand Up @@ -369,7 +506,10 @@ describe('Quiz', () => {
});

it('stays on the stroke if it was incorrect', async () => {
(strokeMatches as any).mockImplementation(() => false);
(strokeMatches as any).mockImplementation(() => ({
isMatch: false,
meta: { isStrokeBackwards: false },
}));

const renderState = createRenderState();
const quiz = new Quiz(
Expand Down Expand Up @@ -409,6 +549,7 @@ describe('Quiz', () => {
{ x: 105, y: 205 },
],
},
isBackwards: false,
});
expect(onCorrectStroke).not.toHaveBeenCalled();
expect(onComplete).not.toHaveBeenCalled();
Expand All @@ -423,7 +564,10 @@ describe('Quiz', () => {
});

it('highlights the stroke if the number of mistakes exceeds showHintAfterMisses', async () => {
(strokeMatches as any).mockImplementation(() => false);
(strokeMatches as any).mockImplementation(() => ({
isMatch: false,
meta: { isStrokeBackwards: false },
}));

const renderState = createRenderState();
const quiz = new Quiz(
Expand Down Expand Up @@ -477,6 +621,7 @@ describe('Quiz', () => {
{ x: 16, y: 26 },
],
},
isBackwards: false,
});
expect(onCorrectStroke).not.toHaveBeenCalled();
expect(onComplete).not.toHaveBeenCalled();
Expand All @@ -495,7 +640,10 @@ describe('Quiz', () => {
});

it('does not highlight strokes if showHintAfterMisses is set to false', async () => {
(strokeMatches as any).mockImplementation(() => false);
(strokeMatches as any).mockImplementation(() => ({
isMatch: false,
meta: { isStrokeBackwards: false },
}));

const renderState = createRenderState();
const quiz = new Quiz(
Expand Down Expand Up @@ -528,7 +676,10 @@ describe('Quiz', () => {
});

it('finishes the quiz when all strokes are successful', async () => {
(strokeMatches as any).mockImplementation(() => true);
(strokeMatches as any).mockImplementation(() => ({
isMatch: true,
meta: { isStrokeBackwards: false },
}));

const renderState = createRenderState();
const quiz = new Quiz(
Expand Down Expand Up @@ -579,6 +730,7 @@ describe('Quiz', () => {
{ x: 16, y: 26 },
],
},
isBackwards: false,
});
expect(onComplete).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenLastCalledWith({
Expand All @@ -603,7 +755,10 @@ describe('Quiz', () => {
});

it('rounds drawn path data', async () => {
(strokeMatches as any).mockImplementation(() => true);
(strokeMatches as any).mockImplementation(() => ({
isMatch: true,
meta: { isStrokeBackwards: false },
}));

const renderState = createRenderState();
const quiz = new Quiz(
Expand Down Expand Up @@ -635,6 +790,7 @@ describe('Quiz', () => {
{ x: 16.9, y: 28.4 },
],
},
isBackwards: false,
});
});
});
Expand All @@ -653,7 +809,10 @@ describe('Quiz', () => {
});

it('doesnt leave strokes partially drawn if the users finishes the quiz really fast', async () => {
(strokeMatches as any).mockImplementation(() => true);
(strokeMatches as any).mockImplementation(() => ({
isMatch: true,
meta: { isStrokeBackwards: false },
}));
const renderState = createRenderState();
const quiz = new Quiz(
char,
Expand Down Expand Up @@ -686,7 +845,10 @@ describe('Quiz', () => {
});

it('sets up character opacities correctly if the users starts drawing during char fading', async () => {
(strokeMatches as any).mockImplementation(() => true);
(strokeMatches as any).mockImplementation(() => ({
isMatch: true,
meta: { isStrokeBackwards: false },
}));
const renderState = createRenderState();
const quiz = new Quiz(
char,
Expand Down
Loading

0 comments on commit 15d37e8

Please sign in to comment.