diff --git a/__tests__/utils.unit.test.js b/__tests__/utils.unit.test.js
new file mode 100644
index 00000000..695dfd73
--- /dev/null
+++ b/__tests__/utils.unit.test.js
@@ -0,0 +1,119 @@
+/* global global */
+
+import { mod, pick, wait, timeoutId } from "../source/utils.js";
+
+// Yes. I generated these tests with ChatGPT. 😎
+
+describe("pick", () => {
+ // Test with an array of numbers
+ test("picks a number from the given array", () => {
+ const options = [1, 2, 3, 4, 5];
+ const result = pick(options);
+ expect(options).toContain(result);
+ });
+
+ // Test with an array of strings
+ test("picks a string from the given array", () => {
+ const options = ["apple", "banana", "cherry", "date"];
+ const result = pick(options);
+ expect(options).toContain(result);
+ });
+
+ // Test with an array of objects
+ test("picks an object from the given array", () => {
+ const options = [
+ { id: 1, name: "Alice" },
+ { id: 2, name: "Bob" },
+ { id: 3, name: "Charlie" },
+ ];
+ const result = pick(options);
+ expect(options).toContain(result);
+ });
+
+ // Test with an empty array
+ test("returns undefined for an empty array", () => {
+ const options = [];
+ const result = pick(options);
+ expect(result).toBeUndefined();
+ });
+
+ // Test with a single-element array
+ test("returns the only option for a single-element array", () => {
+ const options = ["only"];
+ const result = pick(options);
+ expect(result).toEqual("only");
+ });
+
+ // Test with a large array
+ test("picks an element from a large array", () => {
+ const options = Array.from({ length: 1000 }, (_, index) => index);
+ const result = pick(options);
+ expect(options).toContain(result);
+ });
+});
+
+describe("mod", () => {
+ // Test with positive numbers
+ test("returns the correct modulus for positive numbers", () => {
+ expect(mod(10, 3)).toBe(1);
+ expect(mod(15, 6)).toBe(3);
+ expect(mod(20, 7)).toBe(6);
+ });
+
+ // Test with negative numbers
+ test("returns the correct modulus for negative numbers", () => {
+ expect(mod(-10, 3)).toBe(2);
+ expect(mod(-15, 6)).toBe(3);
+ expect(mod(-20, 7)).toBe(1);
+ });
+
+ // Test with zero divisor
+ test("returns NaN when the divisor is zero", () => {
+ expect(mod(10, 0)).toBeNaN();
+ expect(mod(-10, 0)).toBeNaN();
+ expect(mod(0, 0)).toBeNaN();
+ });
+
+ // Test with large numbers
+ test("returns the correct modulus for large numbers", () => {
+ expect(mod(987654321, 123456789)).toBe(9);
+ expect(mod(123456789, 987654321)).toBe(123456789);
+ });
+});
+
+jest.useFakeTimers();
+
+describe("wait", () => {
+ let setTimeoutMock;
+
+ beforeEach(() => {
+ setTimeoutMock = jest.spyOn(global, "setTimeout");
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ setTimeoutMock.mockRestore();
+ });
+
+ test("resolves after the specified delay", () => {
+ const delay = 1000;
+ const promise = wait(delay);
+ expect(setTimeoutMock).toHaveBeenCalledTimes(1);
+ expect(setTimeoutMock).toHaveBeenCalledWith(expect.any(Function), delay);
+ jest.advanceTimersByTime(delay);
+ return expect(promise).resolves.toBeUndefined();
+ });
+
+ test("does not resolve if clearTimeout is called", () => {
+ const delay = 1000;
+ const promise = wait(delay);
+ expect(setTimeoutMock).toHaveBeenCalledTimes(1);
+ expect(setTimeoutMock).toHaveBeenCalledWith(expect.any(Function), delay);
+ clearTimeout(timeoutId);
+ jest.advanceTimersByTime(delay);
+ const passSymbol = Symbol();
+ return expect(
+ Promise.race([promise, Promise.resolve(passSymbol)])
+ ).resolves.toBe(passSymbol);
+ });
+});
diff --git a/source/FortuneCookie/fortuneCookie.js b/source/FortuneCookie/fortuneCookie.js
index 193fe5f3..91d053cf 100644
--- a/source/FortuneCookie/fortuneCookie.js
+++ b/source/FortuneCookie/fortuneCookie.js
@@ -13,6 +13,12 @@ const background = document.getElementById("background");
const resetButton = document.getElementById("reset-button");
const cancelButton = document.getElementById("cancel-animation-btn");
+/**
+ * Stops the current animation and resets all animatable parts of the app to the
+ * specified view (`state`).
+ * @param {'fortune' | 'cookie'} state - Whether to set the view to showing the
+ * fortune (`'fortune'`) or the cookie (`'cookie'`).
+ */
function reset(state) {
clearTimeout(timeoutId);
fortuneAudioCrack.ontimeupdate = null;
@@ -43,7 +49,6 @@ function reset(state) {
cookieButton.disabled = false;
}
}
-window.reset = reset; // TEMP
resetButton.addEventListener("click", () => {
resetButton.disabled = true;
@@ -96,16 +101,23 @@ function disableButton() {
}
/**
- * Enables button so user can click it
+ * A handler called whenever the animation for opening the fortune cookie ends.
*/
function handleFortuneEnd() {
reset("fortune");
}
+/**
+ * A handler called whenever the animation for dropping a new fortune cookie
+ * ends.
+ */
function handleCookieReady() {
reset("cookie");
}
+/**
+ * Sets the animation to make the left half of the cookie fall.
+ */
function fallLeft() {
elem = cookieLeft;
x = 0;
@@ -116,6 +128,9 @@ function fallLeft() {
rotv = -0.05;
shakeIntensity = 10;
}
+/**
+ * Sets the animation to make the right half of the cookie fall.
+ */
function fallRight() {
elem = cookieButton;
x = 0;
@@ -126,6 +141,9 @@ function fallRight() {
rotv = 0.05;
shakeIntensity = 0;
}
+/**
+ * Sets the animation to make the fortune paper fall.
+ */
function fallFortune() {
elem = fortunePaper;
x = 0;
@@ -135,6 +153,9 @@ function fallFortune() {
rot = 0;
rotv = -0.05;
}
+/**
+ * Sets the animation to make a new, full fortune cookie fall.
+ */
function fallNewCookie() {
cookieLeft.style.transform = null;
cookieButton.style.transform = null;
@@ -211,15 +232,34 @@ cancelButton.addEventListener("click", () => {
}
});
-/** in px/ms^2 */
+/**
+ * The acceleration due to "gravity" applied on all falling objects in the
+ * animation, in px/ms^2.
+ * @type {number}
+ */
const GRAVITY = 0.002;
let elem, x, y, xv, yv, rot, rotv;
+/**
+ * The decrease in shake intensity, in px/ms.
+ * @type {number}
+ */
const shakeV = -0.02;
let shakeIntensity;
let cookieY, cookieYV;
+/**
+ * Whether to animate a new cookie falling.
+ * @type {boolean}
+ */
let cookieFalling = false;
+/**
+ * The timestamp of the last time `paint` was called.
+ * @type {number}
+ */
let lastTime = Date.now();
+/**
+ * Draws the next frame of the cookie falling animation.
+ */
function paint() {
const now = Date.now();
const elapsed = Math.min(now - lastTime, 200);
@@ -267,8 +307,20 @@ function paint() {
}
paint();
+/**
+ * A reference to `window.speechSynthesis`.
+ * @type {SpeechSynthesis}
+ */
const synth = window.speechSynthesis;
+/**
+ * The voice selection dropdown.
+ * @type {HTMLSelectElement}
+ */
const voiceSelect = document.querySelector("select");
+/**
+ * A list of voices available by the browser.
+ * @type {SpeechSynthesisVoice[]}
+ */
let voices = [];
/**
diff --git a/source/FortuneCookie/fortunes.js b/source/FortuneCookie/fortunes.js
index 750184b5..265deffe 100644
--- a/source/FortuneCookie/fortunes.js
+++ b/source/FortuneCookie/fortunes.js
@@ -1,5 +1,6 @@
/**
* Array of general, college, and collage-romance type fortunes
+ * @type {string[]}
*/
export const fortunes = [
"Your future is bright, embrace it with open arms.",
diff --git a/source/PalmReading/index.html b/source/PalmReading/index.html
index cd85e13e..fb80a961 100644
--- a/source/PalmReading/index.html
+++ b/source/PalmReading/index.html
@@ -54,7 +54,7 @@
Palm Reading
Results
-
+
Head Line
diff --git a/source/PalmReading/script.js b/source/PalmReading/script.js
index 38bacc35..04cc203b 100644
--- a/source/PalmReading/script.js
+++ b/source/PalmReading/script.js
@@ -8,7 +8,15 @@ export function handleFortune() {
console.log("TODO", fortune);
}
+/**
+ * The tabs' `
` elements.
+ * @type {ArrayLike
}
+ */
const tabLabels = document.querySelectorAll('input[name="tab"]');
+/**
+ * Each tabs' tab content.
+ * @type {ArrayLike}
+ */
const tabContents = document.querySelectorAll(".tab-content");
tabLabels.forEach(function (label) {
diff --git a/source/PalmReading/webcam.js b/source/PalmReading/webcam.js
index 4226b72c..64391a79 100644
--- a/source/PalmReading/webcam.js
+++ b/source/PalmReading/webcam.js
@@ -1,6 +1,11 @@
import { wait } from "../utils.js";
import { handleFortune } from "./script.js";
+/**
+ * The wrapper element that holds all the element anchored around the middle of
+ * the screen, where the circle with the webcam video is.
+ * @type {HTMLDivElement}
+ */
const webcamWrapper = document.getElementById("webcam-wrapper");
/**
* The currently displayed `.instructions` element.
@@ -38,12 +43,40 @@ function setInstructions(instruction) {
* @type {HTMLVideoElement}
*/
const video = document.getElementById("webcam-video");
+/**
+ * The button that requests for camera access.
+ * @type {HTMLButtonElement}
+ */
const requestBtn = document.getElementById("request-webcam");
+/**
+ * The heartbeat graph.
+ * @type {SVGSVGElement}
+ */
const ecgGraph = document.getElementById("ecg");
+/**
+ * A `