File size: 5,401 Bytes
4635d45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <title>runtime tests</title>
</head>
<body>
  <h1>DrawFace runtime unit tests</h1>
  <pre id="result">running…</pre>

  <!-- Standalone: imports only DOM/pure modules (no tracker.js, no network). -->
  <script type="module">
    import {
      Hysteresis, TriStateEye, eyeKeyForUserSide, pickMouth,
    } from "../js/pipeline.js";
    import { prepareCharacter, composeCharacter } from "../js/compositor.js";

    // Match tests/test_camera_mapping.py fixtures (camelCase for the web config).
    const EYES_CFG = {
      closeThreshold: 0.45, openThreshold: 0.30,
      halfCloseThreshold: 0.20, halfOpenThreshold: 0.12,
    };
    const MOUTH_CFG = {
      jawClosed: 0.06, jawMid: 0.16, jawLarge: 0.32,
      puckerThreshold: 0.40, funnelThreshold: 0.35, smileThreshold: 0.30,
    };

    let n = 0;
    const eq = (got, want, msg) => {
      n++;
      const a = JSON.stringify(got), b = JSON.stringify(want);
      if (a !== b) throw new Error(`${msg}: got ${a}, want ${b}`);
    };
    const ok = (cond, msg) => { n++; if (!cond) throw new Error(msg); };
    const throws = (fn, msg) => {
      n++;
      try { fn(); } catch { return; }
      throw new Error(`${msg}: expected throw`);
    };

    function testTriStateBands() {
      const e = new TriStateEye(EYES_CFG);
      eq(e.update(0.05), "open", "tristate start open");
      eq(e.update(0.25), "half", "tristate crossed half_close");
      eq(e.update(0.15), "half", "tristate inside half gap stays half");
      eq(e.update(0.50), "closed", "tristate crossed full close");
      eq(e.update(0.35), "closed", "tristate inside full gap stays closed");
      eq(e.update(0.25), "half", "tristate below open_threshold -> half");
      eq(e.update(0.10), "open", "tristate below half_open -> open");
    }

    function testHysteresisFlicker() {
      const h = new Hysteresis(0.45, 0.30);
      eq(h.update(0.40), false, "hyst below close stays open");
      eq(h.update(0.50), true, "hyst crosses close");
      eq(h.update(0.35), true, "hyst in gap stays closed");
      eq(h.update(0.29), false, "hyst below open reopens");
      eq(h.update(0.31), false, "hyst in gap stays open");
    }

    function testMouthLadder() {
      eq(pickMouth({ jawOpen: 0.0 }, MOUTH_CFG), "closed", "mouth closed");
      eq(pickMouth({ jawOpen: 0.02, mouthSmileLeft: 0.5, mouthSmileRight: 0.5 }, MOUTH_CFG), "smile", "mouth smile");
      eq(pickMouth({ jawOpen: 0.10 }, MOUTH_CFG), "I", "mouth I");
      eq(pickMouth({ jawOpen: 0.20 }, MOUTH_CFG), "E", "mouth E");
      eq(pickMouth({ jawOpen: 0.40 }, MOUTH_CFG), "A", "mouth A");
      eq(pickMouth({ jawOpen: 0.10, mouthPucker: 0.5 }, MOUTH_CFG), "U", "mouth U");
      eq(pickMouth({ jawOpen: 0.20, mouthPucker: 0.5 }, MOUTH_CFG), "O", "mouth O (pucker)");
      eq(pickMouth({ jawOpen: 0.20, mouthFunnel: 0.5 }, MOUTH_CFG), "O", "mouth O (funnel)");
    }

    function testMapping() {
      eq(eyeKeyForUserSide("left", true), "L", "mirror user-left -> L");
      eq(eyeKeyForUserSide("right", true), "R", "mirror user-right -> R");
      eq(eyeKeyForUserSide("left", false), "R", "anatomical user-left -> R");
      eq(eyeKeyForUserSide("right", false), "L", "anatomical user-right -> L");
      for (const mirror of [true, false]) {
        const sides = new Set([eyeKeyForUserSide("left", mirror), eyeKeyForUserSide("right", mirror)]);
        ok(sides.size === 2 && sides.has("L") && sides.has("R"), `mapping exclusive (mirror=${mirror})`);
      }
      throws(() => eyeKeyForUserSide("up", true), "invalid side rejected");
    }

    function coloredCanvas(color) {
      const c = document.createElement("canvas");
      c.width = 512; c.height = 512;
      const x = c.getContext("2d");
      x.fillStyle = color; x.fillRect(0, 0, 512, 512);
      return c;
    }

    function testCompositor() {
      const canvases = { "base.png": coloredCanvas("#ffffff") };
      for (const side of ["L", "R"]) {
        canvases[`eye_${side}_open.png`] = coloredCanvas("#00ff00");
        canvases[`eye_${side}_closed.png`] = coloredCanvas("#ff0000");
      }
      for (const k of ["closed", "A", "E", "I", "O", "U"]) canvases[`mouth_${k}.png`] = coloredCanvas("#0000ff");
      // eye_*_half and mouth_smile intentionally absent -> optional fallback.

      const char = prepareCharacter({ name: "t", manifest: {}, canvases });
      ok(char.eyes.L.half === char.eyes.L.open, "half falls back to open");
      ok(char.mouths.smile === char.mouths.closed, "smile falls back to closed");

      const a = composeCharacter(char, "open", "open", "closed");
      const b = composeCharacter(char, "open", "open", "closed");
      ok(a === b, "compose memoized: same state -> identical object");
      const c = composeCharacter(char, "closed", "open", "closed");
      ok(c !== a, "compose: different state -> different object");
    }

    try {
      testTriStateBands();
      testHysteresisFlicker();
      testMouthLadder();
      testMapping();
      testCompositor();
      document.title = `RUNTIME OK ${n} assertions`;
      document.getElementById("result").textContent = `RUNTIME OK — ${n} assertions passed`;
    } catch (err) {
      document.title = `RUNTIME FAIL ${err.message}`;
      document.getElementById("result").textContent = `RUNTIME FAIL — ${err.message}`;
      throw err;
    }
  </script>
</body>
</html>