Browse Source

Support longer form of small tsu

This redoes a significant part of the state machine system. Instead of
marking boundaries in transitions, we just provide a "meta" number for
each state indicating how many kana we've gone through.
Thomas Dy 3 years ago
parent
commit
86bfde6eaa
5 changed files with 286 additions and 120 deletions
  1. 16 10
      src/display.ts
  2. 48 44
      src/kana.ts
  3. 178 56
      src/state.ts
  4. 43 9
      tests/kana.ts
  5. 1 1
      tsconfig.json

+ 16 - 10
src/display.ts

@@ -54,10 +54,12 @@ class KanaMachineController {
       .map((c) => new SingleKanaDisplayComponent(c));
   }
 
-  observer: state.Observer = (result, boundary) => {
-    if (boundary) {
-      this.children[this.current].setFull();
-      this.current += 1;
+  observer: state.Observer<number> = (result, meta) => {
+    if (meta > this.current) {
+      while (this.current < meta) {
+        this.children[this.current].setFull();
+        this.current += 1;
+      }
     } else if (result != TransitionResult.FAILED) {
       this.children[this.current].setPartial();
     }
@@ -122,7 +124,7 @@ export class RomajiDisplayController {
       this.inputState.map((_, machine) => {
         machine.addObserver(this.observer);
       });
-      this.observer(TransitionResult.SUCCESS, false);
+      this.observer(TransitionResult.SUCCESS, 0, false);
     } else {
       this.firstElement.textContent = '';
       this.restElement.textContent = '';
@@ -137,7 +139,7 @@ export class RomajiDisplayController {
     }
   }
 
-  observer: state.Observer = (result) => {
+  observer: state.Observer<number> = (result) => {
     if (result === TransitionResult.FAILED) {
       this.firstElement.classList.remove('error');
       this.firstElement.offsetHeight; // trigger reflow
@@ -238,7 +240,7 @@ export class Score {
     }
   }
 
-  update(result: TransitionResult, boundary: boolean): void {
+  update(result: TransitionResult, points: number): void {
     if (result === TransitionResult.FAILED) {
       this.missed += 1;
       this.lastMissed = true;
@@ -249,7 +251,7 @@ export class Score {
       this.combo = 0;
     }
 
-    if (boundary) {
+    for (let i = 0; i < points; ++i) {
       if (this.lastSkipped) {
         // no points if we've skipped
         this.lastSkipped = false;
@@ -281,6 +283,7 @@ export class ScoreController {
   skippedElement: HTMLElement;
 
   inputState: InputState | null = null;
+  lastMeta: number;
   score: Score;
 
   constructor(
@@ -294,6 +297,7 @@ export class ScoreController {
     this.hitElement = util.getElement(statsContainer, '.hit');
     this.missedElement = util.getElement(statsContainer, '.missed');
     this.skippedElement = util.getElement(statsContainer, '.skipped');
+    this.lastMeta = 0;
     this.score = new Score();
     this.setValues();
   }
@@ -313,8 +317,10 @@ export class ScoreController {
     this.setValues();
   }
 
-  observer: state.Observer = (result, boundary) => {
-    this.score.update(result, boundary);
+  observer: state.Observer<number> = (result, meta, finished) => {
+    const points = Math.max(0, meta - this.lastMeta);
+    this.lastMeta = finished ? 0 : meta;
+    this.score.update(result, points);
     this.setValues();
   };
 

+ 48 - 44
src/kana.ts

@@ -16,16 +16,26 @@
  */
 
 import * as state from './state';
-import { State, StateMachine, makeTransition as t } from './state';
+import {
+  State,
+  StateMachine,
+  makeTransition as t,
+  mergeMachines,
+  appendMachines,
+  appendStates,
+} from './state';
 
 function literal(source: string, ...extraBoundaries: number[]): StateMachine {
   let transitions: state.Transition[] = [];
+  let meta = 0;
   for (let i = 0; i < source.length; ++i) {
     let from = source.substring(i);
     let input = source.charAt(i);
     let to = source.substring(i + 1);
-    let boundary = i === source.length - 1 || extraBoundaries.indexOf(i) >= 0;
-    transitions.push(t(from, input, to, boundary));
+    if (i === source.length - 1 || extraBoundaries.indexOf(i) >= 0) {
+      meta += 1;
+    }
+    transitions.push(t(from, input, to, meta));
   }
   return state.buildFromTransitions(source, transitions);
 }
@@ -34,8 +44,8 @@ function shi(): StateMachine {
   return state.buildFromTransitions('shi', [
     t('shi', 's', 'hi'),
     t('hi', 'h', 'i'),
-    t('hi', 'i', '', true),
-    t('i', 'i', '', true),
+    t('hi', 'i', '', 1),
+    t('i', 'i', '', 1),
   ]);
 }
 
@@ -44,7 +54,7 @@ function chi(): StateMachine {
     t('chi', 'c', 'hi'),
     t('chi', 't', 'i'),
     t('hi', 'h', 'i'),
-    t('i', 'i', '', true),
+    t('i', 'i', '', 1),
   ]);
 }
 
@@ -52,8 +62,8 @@ function tsu(): StateMachine {
   return state.buildFromTransitions('tsu', [
     t('tsu', 't', 'su'),
     t('su', 's', 'u'),
-    t('su', 'u', '', true),
-    t('u', 'u', '', true),
+    t('su', 'u', '', 1),
+    t('u', 'u', '', 1),
   ]);
 }
 
@@ -61,7 +71,7 @@ function fu(): StateMachine {
   return state.buildFromTransitions('fu', [
     t('fu', 'f', 'u'),
     t('fu', 'h', 'u'),
-    t('u', 'u', '', true),
+    t('u', 'u', '', 1),
   ]);
 }
 
@@ -69,7 +79,7 @@ function ji(): StateMachine {
   return state.buildFromTransitions('ji', [
     t('ji', 'j', 'i'),
     t('ji', 'z', 'i'),
-    t('i', 'i', '', true),
+    t('i', 'i', '', 1),
   ]);
 }
 
@@ -77,10 +87,10 @@ function sh(end: string): StateMachine {
   let source = 'sh' + end;
   let middle = 'h' + end;
   return state.buildFromTransitions(source, [
-    t(source, 's', middle, true),
+    t(source, 's', middle, 1),
     t(middle, 'h', end),
     t(middle, 'y', end),
-    t(end, end, '', true),
+    t(end, end, '', 2),
   ]);
 }
 
@@ -91,65 +101,59 @@ function ch(end: string): StateMachine {
 
   return state.buildFromTransitions(source, [
     t(source, 'c', middle),
-    t(middle, 'h', end, true),
-    t(source, 't', altMiddle, true),
+    t(middle, 'h', end, 1),
+    t(source, 't', altMiddle, 1),
     t(altMiddle, 'y', end),
-    t(end, end, '', true),
+    t(end, end, '', 2),
   ]);
 }
 
 function j(end: string): StateMachine {
-  let source = 'j' + end;
-  let altMiddle = 'y' + end;
-
-  return state.buildFromTransitions(source, [
-    t(source, 'j', end, true),
-    t(source, 'z', altMiddle),
-    t(end, 'y', end),
-    t(altMiddle, 'y', end, true),
-    t(end, end, '', true),
-  ]);
+  return mergeMachines(
+    literal(`j${end}`, 0),
+    literal(`jy${end}`, 0),
+    literal(`zy${end}`, 0)
+  );
 }
 
 function smallTsu(base: StateMachine): StateMachine {
   let { display, transitions } = base.initialState;
 
-  let newState = new State(display.charAt(0) + display);
+  const newState = new State(display.charAt(0) + display, 0);
   Object.keys(transitions).forEach((k) => {
-    let [nextState, boundary] = transitions[k];
-    let intermediateDisplay = k + nextState.display;
-    let intermediateState = new State(intermediateDisplay);
-    intermediateState.addTransition(k, nextState, boundary);
-    newState.addTransition(k, intermediateState, true);
+    const nextState = transitions[k];
+    const intermediateState = new State(k, 0);
+    intermediateState.addTransition(k, new State('', 1));
+    newState.addTransition(k, appendStates(intermediateState, nextState));
   });
 
-  return new StateMachine(newState, base.finalState);
+  return mergeMachines(
+    new StateMachine(newState),
+    appendMachines(
+      state.buildFromTransitions('l', [t('l', 'l', ''), t('l', 'x', '')]),
+      tsu(),
+      base
+    )
+  );
 }
 
 function smallKana(base: StateMachine): StateMachine {
   let newState = base.initialState.clone();
   newState.addTransition('l', base.initialState);
   newState.addTransition('x', base.initialState);
-  return new StateMachine(newState, base.finalState);
+  return new StateMachine(newState);
 }
 
 function n(base: StateMachine): StateMachine {
-  const { display, transitions } = base.initialState;
   const allowSingleN = ['n', 'a', 'i', 'u', 'e', 'o', 'y'].every((k) => {
     return base.initialState.transition(k) === undefined;
   });
 
   if (allowSingleN) {
-    const newState = new State('nn' + display);
-    const middleState = new State('n' + display);
-
-    newState.addTransition('n', middleState, true);
-    middleState.addTransition('n', base.initialState);
-    Object.keys(transitions).forEach((k) => {
-      let [nextState, _] = transitions[k];
-      middleState.addTransition(k, nextState);
-    });
-    return new StateMachine(newState, base.finalState);
+    return mergeMachines(
+      appendMachines(literal('n'), base),
+      appendMachines(literal('nn'), base)
+    );
   } else {
     throw new Error(
       `Invalid base ${base.initialState.display}, just defer to literal`

+ 178 - 56
src/state.ts

@@ -12,98 +12,176 @@ export enum TransitionResult {
   SKIPPED,
 }
 
-interface StateMap {
-  [index: string]: State;
+interface StateMap<Meta> {
+  [index: string]: State<Meta>;
 }
 
-interface StateTransitionList {
-  [index: string]: [State, boolean];
+interface StateTransitionList<Meta> {
+  [index: string]: State<Meta>;
 }
 
-export interface Observer {
-  (result: TransitionResult, boundary: boolean): void;
+export interface Observer<Meta> {
+  (result: TransitionResult, meta: Meta, finished: boolean): void;
 }
 
-export class State {
+export class State<Meta> {
   display: string;
-  transitions: StateTransitionList;
+  meta: Meta;
+  transitions: StateTransitionList<Meta>;
 
-  constructor(display: string) {
+  constructor(display: string, meta: Meta) {
     this.display = display;
+    this.meta = meta;
     this.transitions = {};
   }
 
-  addTransition(input: string, state: State, boundary: boolean = false): void {
-    this.transitions[input] = [state, boundary];
+  addTransition(input: string, state: State<Meta>): void {
+    this.transitions[input] = state;
   }
 
-  transition(input: string): [State, boolean] | undefined {
+  transition(input: string): State<Meta> | undefined {
     return this.transitions[input];
   }
 
-  clone(): State {
-    let state = new State(this.display);
+  merge(other: State<Meta>): State<Meta> {
+    const newState = this.clone();
+    for (const key in other.transitions) {
+      const otherNextState = other.transitions[key];
+      const thisNextState = this.transition(key);
+      if (thisNextState === undefined) {
+        newState.addTransition(key, otherNextState);
+      } else {
+        newState.addTransition(key, thisNextState.merge(otherNextState));
+      }
+    }
+    return newState;
+  }
+
+  transform(fn: (state: State<Meta>) => [string, Meta]): State<Meta> {
+    const [newDisplay, newMeta] = fn(this);
+    const newState = new State(newDisplay, newMeta);
+    for (const key in this.transitions) {
+      newState.transitions[key] = this.transitions[key].transform(fn);
+    }
+    return newState;
+  }
+
+  closure(): Set<State<Meta>> {
+    const closure: Set<State<Meta>> = new Set([this]);
+    for (const key in this.transitions) {
+      const nextState = this.transitions[key];
+      if (!closure.has(nextState)) {
+        nextState.closure().forEach((state) => {
+          closure.add(state);
+        });
+      }
+    }
+    return closure;
+  }
+
+  isEnd(): boolean {
+    return Object.values(this.transitions).length === 0;
+  }
+
+  clone(): State<Meta> {
+    const state = new State(this.display, this.meta);
     state.transitions = { ...this.transitions };
     return state;
   }
+
+  debug(name?: string): this {
+    if (name) {
+      console.group(name);
+    }
+    this.closure().forEach((state) => console.log(state.toJSON()));
+    if (name) {
+      console.groupEnd();
+    }
+    return this;
+  }
+
+  toJSON(): string {
+    const transitions = [];
+    for (const key in this.transitions) {
+      transitions.push(`${key}->${this.transitions[key].display}`);
+    }
+    return `${this.display}(${this.meta}): ${transitions.join(',')}`;
+  }
 }
 
-export class StateMachine {
-  initialState: State;
-  finalState: State;
-  currentState: State;
-  observers: Set<Observer>;
-  nextMachine: StateMachine | null;
+export class MetaStateMachine<Meta> {
+  initialState: State<Meta>;
+  currentState: State<Meta>;
+  observers: Set<Observer<Meta>>;
+  nextMachine: MetaStateMachine<Meta> | null;
 
-  constructor(initialState: State, finalState: State) {
+  constructor(initialState: State<Meta>) {
     this.initialState = initialState;
     this.currentState = initialState;
-    this.finalState = finalState;
     this.observers = new Set();
     this.nextMachine = null;
   }
 
   transition(input: string) {
-    let result = this.currentState.transition(input);
-    if (result == null) {
+    const nextState = this.currentState.transition(input);
+    if (nextState === undefined) {
       this.skipTransition(input);
     } else {
-      let [newState, boundary] = result;
-      this.currentState = newState;
-      this.notifyResult(TransitionResult.SUCCESS, boundary);
+      this.currentState = nextState;
+      this.notifyResult(
+        TransitionResult.SUCCESS,
+        this.currentState.meta,
+        this.currentState.isEnd()
+      );
     }
   }
 
   private skipTransition(input: string): boolean {
-    let potentialNextStates: Array<[State, boolean]> = Object.keys(
+    let potentialNextStates: Array<State<Meta>> = Object.keys(
       this.currentState.transitions
     ).map((k) => this.currentState.transitions[k]);
     for (let i = 0; i < potentialNextStates.length; ++i) {
-      let [state, skippedBoundary] = potentialNextStates[i];
-      if (state === this.finalState) {
+      const state = potentialNextStates[i];
+      if (state.isEnd()) {
         if (this.nextMachine != null) {
           let result = this.nextMachine.initialState.transition(input);
           if (result != null) {
-            const [newState, boundary] = result;
+            const newState = result;
             this.currentState = state;
             this.nextMachine.currentState = newState;
-            this.notifyResult(TransitionResult.SKIPPED, skippedBoundary);
-            this.nextMachine.notifyResult(TransitionResult.SUCCESS, boundary);
+            this.notifyResult(
+              TransitionResult.SKIPPED,
+              state.meta,
+              state.isEnd()
+            );
+            this.nextMachine.notifyResult(
+              TransitionResult.SUCCESS,
+              newState.meta,
+              newState.isEnd()
+            );
             return true;
           }
         }
       } else {
         let result = state.transition(input);
         if (result != null) {
-          let [newState, boundary] = result;
+          const newState = result;
           this.currentState = newState;
-          this.notifyResult(TransitionResult.SKIPPED, skippedBoundary);
-          this.notifyResult(TransitionResult.SUCCESS, boundary);
+          this.notifyResult(
+            TransitionResult.SKIPPED,
+            state.meta,
+            state.isEnd()
+          );
+          this.notifyResult(
+            TransitionResult.SUCCESS,
+            newState.meta,
+            newState.isEnd()
+          );
           return true;
         }
       }
     }
-    this.notifyResult(TransitionResult.FAILED, false);
+    this.notifyResult(TransitionResult.FAILED, this.currentState.meta, false);
     return false;
   }
 
@@ -112,15 +190,15 @@ export class StateMachine {
   }
 
   isFinished(): boolean {
-    return this.currentState === this.finalState;
+    return this.currentState.isEnd();
   }
 
   reset(): void {
     this.currentState = this.initialState;
   }
 
-  clone(): StateMachine {
-    return new StateMachine(this.initialState, this.finalState);
+  clone(): MetaStateMachine<Meta> {
+    return new MetaStateMachine(this.initialState);
   }
 
   getWord(): string {
@@ -131,16 +209,21 @@ export class StateMachine {
     return this.currentState.display;
   }
 
-  addObserver(observer: Observer): void {
+  addObserver(observer: Observer<Meta>): void {
     this.observers.add(observer);
   }
 
-  removeObserver(observer: Observer): void {
+  removeObserver(observer: Observer<Meta>): void {
     this.observers.delete(observer);
   }
 
-  notifyResult(result: TransitionResult, boundary: boolean): void {
-    this.observers.forEach((o) => o(result, boundary));
+  notifyResult(result: TransitionResult, meta: Meta, finished: boolean): void {
+    this.observers.forEach((o) => o(result, meta, finished));
+  }
+
+  debug(): this {
+    this.initialState.debug(this.initialState.display);
+    return this;
   }
 }
 
@@ -148,35 +231,74 @@ export interface Transition {
   from: string;
   input: string;
   to: string;
-  boundary: boolean;
+  meta: number;
 }
 
+export class StateMachine extends MetaStateMachine<number> {}
+
 export function buildFromTransitions(
   initial: string,
   transitions: Transition[]
 ): StateMachine {
-  let states: StateMap = {};
-  function getState(name: string): State {
+  let states: StateMap<number> = {};
+  function getState(name: string, meta: number): State<number> {
     if (states[name] === undefined) {
-      states[name] = new State(name);
+      states[name] = new State(name, meta);
     }
     return states[name];
   }
   transitions.forEach((t) => {
-    let fromState = getState(t.from);
-    let toState = getState(t.to);
-    fromState.addTransition(t.input, toState, t.boundary);
+    let fromState = getState(t.from, 0);
+    let toState = getState(t.to, Math.max(fromState.meta, t.meta));
+    fromState.addTransition(t.input, toState);
   });
-  let initialState = getState(initial);
-  let finalState = getState('');
-  return new StateMachine(initialState, finalState);
+  let initialState = getState(initial, 0);
+  return new StateMachine(initialState);
+}
+
+export function mergeMachines(...machines: StateMachine[]): StateMachine {
+  const newState = machines
+    .map((machine) => machine.initialState)
+    .reduce((acc, state) => acc.merge(state));
+  return new StateMachine(newState);
+}
+
+export function appendMachines(...machines: StateMachine[]): StateMachine {
+  const newState = machines
+    .map((machine) => machine.initialState)
+    .reduce(appendStates);
+  return new StateMachine(newState);
 }
 
 export function makeTransition(
   from: string,
   input: string,
   to: string,
-  boundary: boolean = false
+  meta: number = 0
 ): Transition {
-  return { from, input, to, boundary };
+  return { from, input, to, meta };
+}
+
+export function appendStates(
+  a: State<number>,
+  b: State<number>
+): State<number> {
+  const newState = a.transform((state) => {
+    return [state.display + b.display, state.meta];
+  });
+  const finalStates: Set<State<number>> = new Set();
+  let lastMeta = 0;
+  for (const state of newState.closure()) {
+    if (state.isEnd()) {
+      lastMeta = state.meta;
+      finalStates.add(state);
+    }
+  }
+  const { transitions } = b.transform((state) => {
+    return [state.display, state.meta + lastMeta];
+  });
+  finalStates.forEach((finalState) => {
+    finalState.transitions = transitions;
+  });
+  return newState;
 }

+ 43 - 9
tests/kana.ts

@@ -8,9 +8,9 @@ function testInput(input: string, line: string) {
   const inputState = new KanaInputState(line);
   let kanaCount = 0;
   inputState.map((_, m) => {
-    m.addObserver((result, boundary) => {
-      if (boundary) {
-        kanaCount += 1;
+    m.addObserver((result, meta) => {
+      if (m.isFinished()) {
+        kanaCount += meta;
       }
       assert.is(
         result,
@@ -48,6 +48,34 @@ function testFail(input: string, line: string) {
   assert.ok(fail, `Expected ${input} to fail on ${line}`);
 }
 
+function testSkip(input: string, line: string, expectedSkips: number) {
+  const inputState = new KanaInputState(line);
+  let kanaCount = 0;
+  let skipCount = 0;
+  inputState.map((_, m) => {
+    m.addObserver((result, meta) => {
+      if (result === TransitionResult.SKIPPED) {
+        kanaCount += meta;
+        skipCount += 1;
+      } else if (result === TransitionResult.SUCCESS) {
+        kanaCount += meta;
+      } else {
+        assert.unreachable(`Expected ${input} to match ${line}`);
+      }
+    });
+  });
+  for (const c of input.split('')) {
+    inputState.handleInput(c);
+  }
+  assert.ok(inputState.isFinished(), `Expected inputState to be finished`);
+  assert.is(
+    kanaCount,
+    line.length,
+    `Expected ${line.length} boundaries, got ${kanaCount}`
+  );
+  assert.is(skipCount, expectedSkips, `Expected skip count to match`);
+}
+
 test('normalizeInput', () => {
   assert.is(normalizeInput('ABCdef'), 'abcdef');
   assert.is(normalizeInput('フェスティバル'), 'ふぇすてぃばる');
@@ -88,16 +116,16 @@ test('multiple romanization double kana', () => {
 
 test('small tsu', () => {
   testInput('katto', 'カット');
-  // testInput('kaltsuto', 'かっと');
-  // testInput('kaltuto', 'かっと');
+  testInput('kaltsuto', 'かっと');
+  testInput('kaltuto', 'かっと');
   testInput('ejji', 'エッジ');
   testInput('ezzi', 'エッジ');
-  // testInput('extuji', 'エッジ');
-  // testInput('extsuzi', 'エッジ');
+  testInput('extuji', 'エッジ');
+  testInput('extsuzi', 'エッジ');
   testInput('hassha', 'はっしゃ');
   testInput('hassya', 'はっしゃ');
-  // testInput('haltusha', 'はっしゃ');
-  // testInput('haltusya', 'はっしゃ');
+  testInput('haltusha', 'はっしゃ');
+  testInput('haltusya', 'はっしゃ');
 });
 
 test('nn', () => {
@@ -114,4 +142,10 @@ test('nn', () => {
   testInput('nnnya', 'んにゃ');
 });
 
+test('skipping', () => {
+  testSkip('a', 'は', 1);
+  testSkip('hao', 'はろ', 1);
+  testSkip('hro', 'はろ', 1);
+});
+
 test.run();

+ 1 - 1
tsconfig.json

@@ -6,7 +6,7 @@
     "module": "es6",
     "moduleResolution": "node",
     "outDir": "dist",
-    "target": "es6",
+    "target": "es2017",
     "types": []
   },
   "include": [