Repository: chanind/hanzi-writer
Pull request #252 (2021-09-15 01:09:13)

feat: allow checking backwards strokes
The use case for this is to allow applications to notify a user when they make a backwards stroke (one which is correct except for the direction in which in which it's made) on a quiz. In such a case, the application will likely want to show some messaging to the user indicating the stroke was made in the wrong direction. The application may want to treat backwards strokes as correct or incorrect.

This change includes two API changes to fulfill the above use case:
- The addition of a `checkBackwardsStrokes` ternary quiz option (defaulted off)
- The `onCorrectStroke` and `onMistake` quiz handlers receive an `isBackwards` boolean property on the stroke data.

`checkBackwardsStrokes` is ternary to allow avoiding the perf hit of checking backwards strokes if the application does not care about them.

If `checkBackwardsStrokes` is `false`, the handlers will always receive `isBackwards: false` since the backwards check is skipped. The alternative here is to return e.g. `null` if the check was skipped. I opted against this to keep the API a bit simpler with a consistent type for `isBackwards`.

codecov-commenter: # [Codecov](https://codecov.io/gh/chanind/hanzi-writer/pull/252?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Chanin) Report
> Merging [#252](https://codecov.io/gh/chanind/hanzi-writer/pull/252?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Chanin) (5faf580) into [master](https://codecov.io/gh/chanind/hanzi-writer/commit/f1a810c471f840d2ffd54f5879b7cb3afa5d6837?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Chanin) (f1a810c) will **increase** coverage by `0.03%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/chanind/hanzi-writer/pull/252/graphs/tree.svg?width=650&height=150&src=pr&token=SMtPsyLoII&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Chanin)](https://codecov.io/gh/chanind/hanzi-writer/pull/252?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Chanin)

```diff
@@            Coverage Diff             @@
##           master     #252      +/-   ##
==========================================
+ Coverage   96.16%   96.19%   +0.03%     
==========================================
  Files          32       32              
  Lines        1121     1131      +10     
  Branches      202      207       +5     
==========================================
+ Hits         1078     1088      +10     
  Misses         39       39              
  Partials        4        4              
```


| [Impacted Files](https://codecov.io/gh/chanind/hanzi-writer/pull/252?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Chanin) | Coverage Δ | |
|---|---|---|
| [src/defaultOptions.ts](https://codecov.io/gh/chanind/hanzi-writer/pull/252/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Chanin#diff-c3JjL2RlZmF1bHRPcHRpb25zLnRz) | `100.00% <ø> (ø)` | |
| [src/Quiz.ts](https://codecov.io/gh/chanind/hanzi-writer/pull/252/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Chanin#diff-c3JjL1F1aXoudHM=) | `95.89% <100.00%> (+0.11%)` | :arrow_up: |
| [src/strokeMatches.ts](https://codecov.io/gh/chanind/hanzi-writer/pull/252/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Chanin#diff-c3JjL3N0cm9rZU1hdGNoZXMudHM=) | `98.85% <100.00%> (+0.11%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/chanind/hanzi-writer/pull/252?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Chanin).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Chanin)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/chanind/hanzi-writer/pull/252?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Chanin). Last update [f1a810c...5faf580](https://codecov.io/gh/chanind/hanzi-writer/pull/252?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Chanin). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Chanin).


Patch:
diff --git a/src/Quiz.ts b/src/Quiz.ts
index f96ba16b..b53b0f3b 100644
--- a/src/Quiz.ts
+++ b/src/Quiz.ts
@@ -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';
@@ -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,
@@ -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,
@@ -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,
@@ -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;
@@ -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',
@@ -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() {
diff --git a/src/defaultOptions.ts b/src/defaultOptions.ts
index 668652f1..f96e26e7 100644
--- a/src/defaultOptions.ts
+++ b/src/defaultOptions.ts
@@ -38,6 +38,7 @@ const defaultOptions: HanziWriterOptions = {
   showHintAfterMisses: 3,
   highlightOnComplete: true,
   highlightCompleteColor: null,
+  acceptBackwardsStrokes: false,
 
   // undocumented obscure options
 
diff --git a/src/strokeMatches.ts b/src/strokeMatches.ts
index e8a35213..ea2beb75 100644
--- a/src/strokeMatches.ts
+++ b/src/strokeMatches.ts
@@ -20,6 +20,15 @@ const START_AND_END_DIST_THRESHOLD = 250; // bigger = more lenient
 const FRECHET_THRESHOLD = 0.4; // bigger = more lenient
 const MIN_LEN_THRESHOLD = 0.35; // smaller = more lenient
 
+export interface StrokeMatchResultMeta {
+  isStrokeBackwards: boolean;
+}
+
+export interface StrokeMatchResult {
+  isMatch: boolean;
+  meta: StrokeMatchResultMeta;
+}
+
 export default function strokeMatches(
   userStroke: UserStroke,
   character: Character,
@@ -28,18 +37,18 @@ export default function strokeMatches(
     leniency?: number;
     isOutlineVisible?: boolean;
   } = {},
-) {
+): StrokeMatchResult {
   const strokes = character.strokes;
   const points = stripDuplicates(userStroke.points);
 
   if (points.length < 2) {
-    return null;
+    return { isMatch: false, meta: { isStrokeBackwards: false } };
   }
 
-  const { isMatch, avgDist } = getMatchData(points, strokes[strokeNum], options);
+  const { isMatch, meta, avgDist } = getMatchData(points, strokes[strokeNum], options);
 
   if (!isMatch) {
-    return false;
+    return { isMatch, meta };
   }
 
   // if there is a better match among strokes the user hasn't drawn yet, the user probably drew the wrong stroke
@@ -47,7 +56,10 @@ export default function strokeMatches(
   let closestMatchDist = avgDist;
 
   for (let i = 0; i < laterStrokes.length; i++) {
-    const { isMatch, avgDist } = getMatchData(points, laterStrokes[i], options);
+    const { isMatch, avgDist } = getMatchData(points, laterStrokes[i], {
+      ...options,
+      checkBackwards: false,
+    });
     if (isMatch && avgDist < closestMatchDist) {
       closestMatchDist = avgDist;
     }
@@ -57,13 +69,14 @@ export default function strokeMatches(
   if (closestMatchDist < avgDist) {
     // adjust leniency between 0.3 and 0.6 depending on how much of a better match the new match is
     const leniencyAdjustment = (0.6 * (closestMatchDist + avgDist)) / (2 * avgDist);
-    const { isMatch } = getMatchData(points, strokes[strokeNum], {
+    const { isMatch, meta } = getMatchData(points, strokes[strokeNum], {
       ...options,
       leniency: (options.leniency || 1) * leniencyAdjustment,
     });
-    return isMatch;
+    return { isMatch, meta };
   }
-  return true;
+
+  return { isMatch, meta };
 }
 
 const startAndEndMatches = (points: Point[], closestStroke: Stroke, leniency: number) => {
@@ -143,23 +156,38 @@ const shapeFit = (curve1: Point[], curve2: Point[], leniency: number) => {
 const getMatchData = (
   points: Point[],
   stroke: Stroke,
-  options: { leniency?: number; isOutlineVisible?: boolean },
-) => {
-  const { leniency = 1, isOutlineVisible = false } = options;
+  options: { leniency?: number; isOutlineVisible?: boolean; checkBackwards?: boolean },
+): StrokeMatchResult & { avgDist: number } => {
+  const { leniency = 1, isOutlineVisible = false, checkBackwards = true } = options;
   const avgDist = stroke.getAverageDistance(points);
   const distMod = isOutlineVisible || stroke.strokeNum > 0 ? 0.5 : 1;
   const withinDistThresh = avgDist <= AVG_DIST_THRESHOLD * distMod * leniency;
   // short circuit for faster matching
   if (!withinDistThresh) {
-    return { isMatch: false, avgDist };
+    return { isMatch: false, avgDist, meta: { isStrokeBackwards: false } };
   }
   const startAndEndMatch = startAndEndMatches(points, stroke, leniency);
   const directionMatch = directionMatches(points, stroke);
   const shapeMatch = shapeFit(points, stroke.points, leniency);
   const lengthMatch = lengthMatches(points, stroke, leniency);
-  return {
-    isMatch:
-      withinDistThresh && startAndEndMatch && directionMatch && shapeMatch && lengthMatch,
-    avgDist,
-  };
+
+  const isMatch =
+    withinDistThresh && startAndEndMatch && directionMatch && shapeMatch && lengthMatch;
+
+  if (checkBackwards && !isMatch) {
+    const backwardsMatchData = getMatchData([...points].reverse(), stroke, {
+      ...options,
+      checkBackwards: false,
+    });
+
+    if (backwardsMatchData.isMatch) {
+      return {
+        isMatch,
+        avgDist,
+        meta: { isStrokeBackwards: true },
+      };
+    }
+  }
+
+  return { isMatch, avgDist, meta: { isStrokeBackwards: false } };
 };
diff --git a/src/typings/types.ts b/src/typings/types.ts
index 1d9bbd68..19a6b8b4 100644
--- a/src/typings/types.ts
+++ b/src/typings/types.ts
@@ -52,6 +52,7 @@ export type StrokeData = {
     pathString: string;
     points: Point[];
   };
+  isBackwards: boolean;
   strokeNum: number;
   mistakesOnStroke: number;
   totalMistakes: number;
@@ -65,6 +66,8 @@ export type QuizOptions = {
   showHintAfterMisses: number | false;
   /** After a quiz is completed successfully, the character will flash briefly. Default: true */
   highlightOnComplete: boolean;
+  /** Whether to treat strokes which are correct besides their direction as correct. */
+  acceptBackwardsStrokes: boolean;
   onMistake?: (strokeData: StrokeData) => void;
   onCorrectStroke?: (strokeData: StrokeData) => void;
   /** Callback when the quiz completes */


Test patch:
diff --git a/src/__tests__/Quiz-test.ts b/src/__tests__/Quiz-test.ts
index 401523e5..a1d045ea 100644
--- a/src/__tests__/Quiz-test.ts
+++ b/src/__tests__/Quiz-test.ts
@@ -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(
@@ -318,6 +321,7 @@ describe('Quiz', () => {
             { x: 105, y: 205 },
           ],
         },
+        isBackwards: false,
       });
       expect(onMistake).not.toHaveBeenCalled();
       expect(onComplete).not.toHaveBeenCalled();
@@ -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(
@@ -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(
@@ -409,6 +549,7 @@ describe('Quiz', () => {
             { x: 105, y: 205 },
           ],
         },
+        isBackwards: false,
       });
       expect(onCorrectStroke).not.toHaveBeenCalled();
       expect(onComplete).not.toHaveBeenCalled();
@@ -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(
@@ -477,6 +621,7 @@ describe('Quiz', () => {
             { x: 16, y: 26 },
           ],
         },
+        isBackwards: false,
       });
       expect(onCorrectStroke).not.toHaveBeenCalled();
       expect(onComplete).not.toHaveBeenCalled();
@@ -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(
@@ -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(
@@ -579,6 +730,7 @@ describe('Quiz', () => {
             { x: 16, y: 26 },
           ],
         },
+        isBackwards: false,
       });
       expect(onComplete).toHaveBeenCalledTimes(1);
       expect(onComplete).toHaveBeenLastCalledWith({
@@ -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(
@@ -635,6 +790,7 @@ describe('Quiz', () => {
             { x: 16.9, y: 28.4 },
           ],
         },
+        isBackwards: false,
       });
     });
   });
@@ -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,
@@ -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,
diff --git a/src/__tests__/strokeMatches-test.ts b/src/__tests__/strokeMatches-test.ts
index 20ce81ac..15f5ae88 100644
--- a/src/__tests__/strokeMatches-test.ts
+++ b/src/__tests__/strokeMatches-test.ts
@@ -19,7 +19,7 @@ const assertMatches = (
 ) => {
   const char = getChar(charStr);
   const userStroke = { points } as any;
-  expect(strokeMatches(userStroke, char, strokeNum, options)).toBe(true);
+  expect(strokeMatches(userStroke, char, strokeNum, options).isMatch).toBe(true);
 };
 
 const assertNotMatches = (
@@ -30,10 +30,28 @@ const assertNotMatches = (
 ) => {
   const char = getChar(charStr);
   const userStroke = { points } as any;
-  expect(strokeMatches(userStroke, char, strokeNum, options)).toBe(false);
+  expect(strokeMatches(userStroke, char, strokeNum, options).isMatch).toBe(false);
 };
 
 describe('strokeMatches', () => {
+  it('does not match if the user stroke is a single point', () => {
+    const stroke = new Stroke(
+      '',
+      [
+        { x: 0, y: 0 },
+        { x: 10, y: 50 },
+      ],
+      0,
+    );
+
+    const userStroke = new UserStroke(1, { x: 2, y: 1 }, { x: 9999, y: 9999 });
+
+    expect(strokeMatches(userStroke, new Character('X', [stroke]), 0)).toEqual({
+      isMatch: false,
+      meta: { isStrokeBackwards: false },
+    });
+  });
+
   it('matches if the user stroke roughly matches the stroke medians', () => {
     const stroke = new Stroke(
       '',
@@ -48,7 +66,10 @@ describe('strokeMatches', () => {
     userStroke.appendPoint({ x: 5, y: 25 }, { x: 9999, y: 9999 });
     userStroke.appendPoint({ x: 10, y: 51 }, { x: 9999, y: 9999 });
 
-    expect(strokeMatches(userStroke, new Character('X', [stroke]), 0)).toBe(true);
+    expect(strokeMatches(userStroke, new Character('X', [stroke]), 0)).toEqual({
+      isMatch: true,
+      meta: { isStrokeBackwards: false },
+    });
   });
 
   it('does not match if the user stroke is in the wrong direction', () => {
@@ -65,7 +86,10 @@ describe('strokeMatches', () => {
     userStroke.appendPoint({ x: 5, y: 25 }, { x: 9999, y: 9999 });
     userStroke.appendPoint({ x: 2, y: 1 }, { x: 9999, y: 9999 });
 
-    expect(strokeMatches(userStroke, new Character('X', [stroke]), 0)).toBe(false);
+    expect(strokeMatches(userStroke, new Character('X', [stroke]), 0)).toEqual({
+      isMatch: false,
+      meta: { isStrokeBackwards: true },
+    });
   });
 
   it('does not match if the user stroke is too far away', () => {
@@ -86,7 +110,10 @@ describe('strokeMatches', () => {
     userStroke.appendPoint({ x: 5 + 200, y: 25 + 200 }, { x: 9999, y: 9999 });
     userStroke.appendPoint({ x: 10 + 200, y: 51 + 200 }, { x: 9999, y: 9999 });
 
-    expect(strokeMatches(userStroke, new Character('X', [stroke]), 0)).toBe(false);
+    expect(strokeMatches(userStroke, new Character('X', [stroke]), 0)).toEqual({
+      isMatch: false,
+      meta: { isStrokeBackwards: false },
+    });
   });
 
   it('is more lenient depending on how large leniency is', () => {
@@ -109,10 +136,10 @@ describe('strokeMatches', () => {
 
     expect(
       strokeMatches(userStroke, new Character('X', [stroke]), 0, { leniency: 0.2 }),
-    ).toBe(false);
+    ).toEqual({ isMatch: false, meta: { isStrokeBackwards: false } });
     expect(
       strokeMatches(userStroke, new Character('X', [stroke]), 0, { leniency: 20 }),
-    ).toBe(true);
+    ).toEqual({ isMatch: true, meta: { isStrokeBackwards: false } });
   });
 
   it('matches using real data 1', () => {