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 `` that stores a snapshot of the webcam video. + * @type {HTMLCanvasElement} + */ const result = document.getElementById("result-palm"); +/** + * The `CanvasRenderingContext2D` for `result`. + * @type {CanvasRenderingContext2D} + */ const context = result.getContext("2d"); +/** + * The button for resetting the app and reading another hand. + * @type {HTMLButtonElement} + */ const readAnother = document.getElementById("read-another-hand"); +/** + * Whether the camera should be horizontally flipped (for front-facing cameras). + * @type {boolean} + */ let flipCamera = true; +/** + * Handler for the "Begin" button that requests for camera access and turns on + * the webcam. + */ async function startCamera() { document.body.classList.remove("show-results"); requestBtn.parentNode.style.display = "none"; @@ -109,7 +142,12 @@ video.addEventListener("loadedmetadata", async () => { }); readAnother.addEventListener("click", startCamera); -// 20 units is about 0.6s, so 1 s = 33ish units +/** + * Some points on an image of an ECG graph I found on Google Images that I + * manually marked out in MS Paint. Used to form the piecewise linear `ecg` + * polyline. + * @type {[number, number][]} + */ const ecgPoints = [ [-0.8, 0.2], [0.4, 1.2], @@ -126,9 +164,31 @@ const ecgPoints = [ [16.8, 1.1], [18, 0.2], ]; +/** + * Horizontal shift factor to be added to each point in `ecgPoints`. This is + * because the image I got wasn't centered at the origin. + * @type {number} + */ const XSHIFT = 0.8; +/** + * Vertical shift factor to be added to each point in `ecgPoints`. + * @type {number} + */ const YSHIFT = -0.2; +/** + * The period of the heartbeat shape, in whatever units `ecgPoints` is in. + * Increase to increase the spacing between heartbeats, but it's mostly a + * guessing game. + */ const PERIOD = 30; +/** + * A periodic, piecewiese linear function that draws out the shape of an ECG. + * The units used in this function are kind of weird, so some guesswork is + * required to shape it into a nice-looking form for the heartbeat graph. + * + * @param {number} time - The x-value of the ECG graph. + * @returns {number} The resulting y-value of the graph. + */ function ecg(time) { time = time % PERIOD; const index = ecgPoints.findIndex(([x]) => x + XSHIFT > time); @@ -143,13 +203,54 @@ function ecg(time) { ); } +/** + * The number of SVG units in width of the fake heartbeat graph. + * @type {number} + */ const ECG_LENGTH = 300; +/** + * The FPS of the animation. `paintEcg` will try to keep the animation at this + * rate, for displays that have slower or faster refresh rates. + * @type {number} + */ const FPS = 60; +/** + * The `` element that draws the ECG graph. + * @type {SVGPathElement} + */ const ecgPath = document.getElementById("ecg-path"); +/** + * A queue of points (new elements added to the beginning) representing the ECG + * graph. Kept to a maximum of `ECG_LENGTH` items. + * @type {number[]} + */ const ecgHistory = []; +/** + * Tracks how much time has been "simulated" by `paintEcg`. This is used for + * refresh rate independence. For example, if there was a lag spike and real + * time passes more than usual by the next animation frame, then `paintEcg` + * might simulate two simulation "frames" in the same animation frame so the + * animation doesn't slow down. + * @type {number} + */ let simTime = 0; +/** + * The time when animation and simulation started. + * @type {number} + */ let startTime = Date.now(); +/** + * The ID returned by `window.requestAnimationFrame`, used to cancel it or + * determine whether it is animating. `null` if `paintEcg` is not animating. + * @type {number | null} + */ let frameId = null; +/** + * Draws the next frame of the fake heartbeat ECG graph animation. Once + * `frameId` is set to `null`, the animation stops. + * + * This should be display refresh rate independent. + */ function paintEcg() { if (frameId === null) { return; diff --git a/source/Zodiac_compatibility/data/dataArray.js b/source/Zodiac_compatibility/data/dataArray.js index 4fa32a5a..546a11ff 100644 --- a/source/Zodiac_compatibility/data/dataArray.js +++ b/source/Zodiac_compatibility/data/dataArray.js @@ -1,3 +1,7 @@ +/** + * A mapping of modulo'd angles to Zodiacs for the left wheel. + * @type {[number, string][]} + */ const zodiacAngleMappingLeft = [ [0, "Capricorn"], [360, "Capricorn"], @@ -35,6 +39,10 @@ const zodiacAngleMappingLeft = [ [330, "Aquarius"], [-30, "Aquarius"], ]; +/** + * A mapping of modulo'd angles to Zodiacs for the right wheel. + * @type {[number, string][]} + */ const zodiacAngleMappingRight = [ [0, "Cancer"], [360, "Cancer"], @@ -73,6 +81,10 @@ const zodiacAngleMappingRight = [ [-30, "Leo"], ]; +/** + * Mapping of zodiacs to their date range. + * @type {Record} + */ const zodiacDateRanges = { Capricorn: "Dec 22 - Jan 19", Sagittarius: "Nov 22 - Dec 21", @@ -87,6 +99,10 @@ const zodiacDateRanges = { Pisces: "Feb 19 - Mar 20", Aquarius: "Jan 20 - Feb 18", }; +/** + * Mapping of zodiac pairs to their college romance analysis. + * @type {Map} + */ const romantic = new Map([ [ "Aries and Taurus", diff --git a/source/common/index.js b/source/common/index.js index 2ddd910e..52f1fa0c 100644 --- a/source/common/index.js +++ b/source/common/index.js @@ -1 +1,6 @@ +/** + * @file A JavaScript file that is included on every page. If we want to apply a + * script onto every page for consistency, this is the place to do so. + */ + import "./nav.js"; diff --git a/source/common/nav.js b/source/common/nav.js index ff0bada9..71079cf4 100644 --- a/source/common/nav.js +++ b/source/common/nav.js @@ -1,3 +1,23 @@ +// @ts-check + +/** + * @file Creates a navigation menu that gets added to all pages on the web app, + * linking all pages with each other. + */ + +/** + * Defines an entry in the navigation menu. + * @typedef {object} Link + * @property {string} label - The text to show for the link. + * @property {string} url - The URL of the page to link to. + * @property {string} imageUrl - The URL of the image icon to show next to the + * link text. + */ + +/** + * The list of entries in the navigation menu. + * @type {Link[]} + */ const links = [ { label: "Home", @@ -23,9 +43,19 @@ const links = [ }, ]; +/** + * The created `