Repository: chanind/hanzi-writer (ts)

Issue:
quiz mode: if I made a mistake 2 times, I'd like the stroke to be drawn for me
I see that I have access to the writer object from onMistake callback. However, I did not understand if it's possible to command the writer to count my mistake as non-mistake and draw the stroke properly. 

The problem that I'm solving is this: some strokes are hard to get right, so the user spends a lot of time trying to be precise, drawing the stroke again and again. I counted that it takes 1.5 - 3 times more to draw a kanji using this strategy than when the stroke is autodrawn after a couple of attempts. When we talk about 100 reviews a day, this is a difference between 20 min of study and a full hour, every day. 

Pull request description:
feat: adding markStrokeCorrectAfterMisses option to quizzes
This PR adds a `markStrokeCorrectAfterMisses` option to `new HanziWriter()` and `HanziWriter.quiz()` which allows setting a number of mistakes after which the users's stroke will be marked correct no matter what. Thank you to @ ganqqwerty for the feature suggestion!

closes #289

Patch:
diff --git a/src/Quiz.ts b/src/Quiz.ts
index cf7de4ff..e6a1616a 100644
--- a/src/Quiz.ts
+++ b/src/Quiz.ts
@@ -100,7 +100,7 @@ export default class Quiz {
       return;
     }
 
-    const { acceptBackwardsStrokes } = this._options!;
+    const { acceptBackwardsStrokes, markStrokeCorrectAfterMisses } = this._options!;
 
     const currentStroke = this._getCurrentStroke();
     const { isMatch, meta } = strokeMatches(
@@ -113,7 +113,13 @@ export default class Quiz {
       },
     );
 
-    const isAccepted = isMatch || (meta.isStrokeBackwards && acceptBackwardsStrokes);
+    // if markStrokeCorrectAfterMisses is passed, just force the stroke to count as correct after n tries
+    const isForceAccepted =
+      markStrokeCorrectAfterMisses &&
+      this._mistakesOnStroke + 1 >= markStrokeCorrectAfterMisses;
+
+    const isAccepted =
+      isMatch || isForceAccepted || (meta.isStrokeBackwards && acceptBackwardsStrokes);
 
     if (isAccepted) {
       this._handleSuccess(meta);
diff --git a/src/defaultOptions.ts b/src/defaultOptions.ts
index b7682ff7..9994b5c6 100644
--- a/src/defaultOptions.ts
+++ b/src/defaultOptions.ts
@@ -38,6 +38,7 @@ const defaultOptions: HanziWriterOptions = {
   showHintAfterMisses: 3,
   highlightOnComplete: true,
   highlightCompleteColor: null,
+  markStrokeCorrectAfterMisses: false,
   acceptBackwardsStrokes: false,
   quizStartStrokeNum: 0,
 
diff --git a/src/typings/types.ts b/src/typings/types.ts
index e6f4e8b7..f3ac2967 100644
--- a/src/typings/types.ts
+++ b/src/typings/types.ts
@@ -70,6 +70,8 @@ export type QuizOptions = {
   acceptBackwardsStrokes: boolean;
   /** Begin quiz on this stroke number rather than stroke 0 */
   quizStartStrokeNum: number;
+  /** After a user makes this many mistakes, just mark the stroke correct and move on. Default: false */
+  markStrokeCorrectAfterMisses: number | false;
   onMistake?: (strokeData: StrokeData) => void;
   onCorrectStroke?: (strokeData: StrokeData) => void;
   /** Callback when the quiz completes */


Test patch:
diff --git a/demo/test.js b/demo/test.js
index 62c757a1..7416245a 100644
--- a/demo/test.js
+++ b/demo/test.js
@@ -17,7 +17,6 @@ function updateCharacter() {
     height: 400,
     renderer: 'svg',
     radicalColor: '#166E16',
-    highlightCompleteColor: '#FF0000',
     onCorrectStroke: printStrokePoints,
     onMistake: printStrokePoints,
     showCharacter: false,
diff --git a/src/__tests__/Quiz-test.ts b/src/__tests__/Quiz-test.ts
index 6565b69f..5b662e75 100644
--- a/src/__tests__/Quiz-test.ts
+++ b/src/__tests__/Quiz-test.ts
@@ -699,6 +699,93 @@ describe('Quiz', () => {
       expect(renderState.state.character.highlight.strokes[0].opacity).toBe(0);
     });
 
+    it('marks the stroke as correct if the number of mistakes exceeds markStrokeCorrectAfterMisses', async () => {
+      (strokeMatches as any).mockImplementation(() => ({
+        isMatch: false,
+        meta: { isStrokeBackwards: false },
+      }));
+
+      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,
+          markStrokeCorrectAfterMisses: 2,
+        }),
+      );
+      clock.tick(1000);
+      await resolvePromises();
+
+      quiz.startUserStroke({ x: 10, y: 20 });
+      quiz.continueUserStroke({ x: 11, y: 21 });
+      quiz.endUserStroke();
+      await resolvePromises();
+      clock.tick(1000);
+      await resolvePromises();
+
+      // should not draw the stroke yet
+      expect(quiz._currentStrokeIndex).toBe(0);
+      expect(renderState.state.character.main.strokes[0].opacity).toBe(0);
+
+      quiz.startUserStroke({ x: 10, y: 20 });
+      quiz.continueUserStroke({ x: 11, y: 21 });
+      quiz.endUserStroke();
+      await resolvePromises();
+
+      expect(quiz._userStroke).toBeUndefined();
+      expect(quiz._isActive).toBe(true);
+      expect(quiz._currentStrokeIndex).toBe(1);
+      expect(onMistake).toHaveBeenCalledTimes(1);
+      expect(onMistake).toHaveBeenLastCalledWith({
+        character: '人',
+        mistakesOnStroke: 1,
+        strokeNum: 0,
+        strokesRemaining: 2,
+        totalMistakes: 1,
+        drawnPath: {
+          pathString: 'M 10 20 L 11 21',
+          points: [
+            { x: 15, y: 25 },
+            { x: 16, y: 26 },
+          ],
+        },
+        isBackwards: false,
+      });
+      expect(onCorrectStroke).toHaveBeenCalledTimes(1);
+      expect(onCorrectStroke).toHaveBeenLastCalledWith({
+        character: '人',
+        mistakesOnStroke: 1,
+        strokeNum: 0,
+        strokesRemaining: 1,
+        totalMistakes: 1,
+        drawnPath: {
+          pathString: 'M 10 20 L 11 21',
+          points: [
+            { x: 15, y: 25 },
+            { x: 16, y: 26 },
+          ],
+        },
+        isBackwards: false,
+      });
+
+      clock.tick(1000);
+      await resolvePromises();
+
+      // should draw the stroke now
+      clock.tick(1000);
+      await resolvePromises();
+      expect(renderState.state.character.main.strokes[0].opacity).toBe(1);
+    });
+
     it('does not highlight strokes if showHintAfterMisses is set to false', async () => {
       (strokeMatches as any).mockImplementation(() => ({
         isMatch: false,