diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..60a1e304 Binary files /dev/null and b/.DS_Store differ diff --git a/example/.DS_Store b/example/.DS_Store new file mode 100644 index 00000000..6959bc39 Binary files /dev/null and b/example/.DS_Store differ diff --git a/src/TrackStates.ts b/src/TrackStates.ts index 405fd511..53414d60 100644 --- a/src/TrackStates.ts +++ b/src/TrackStates.ts @@ -85,17 +85,17 @@ export class LoadingState implements ICommonStateProperties { */ export class TimedTrackState implements ICommonStateProperties { track: PlaylistAudiotrack; - windowScope: Window; + trackOptions: TrackOptions; - timerId: null | number; + timerId: null | NodeJS.Timeout; // for logging purpse; - intervalId: null | number; + intervalId: null | NodeJS.Timeout; timeRemainingMs?: number; timerApproximateEndingAtMs?: number; constructor(track: PlaylistAudiotrack, trackOptions: TrackOptions) { this.track = track; - this.windowScope = track.windowScope; + this.trackOptions = trackOptions; this.timerId = null; this.intervalId = null; @@ -146,10 +146,10 @@ export class TimedTrackState implements ICommonStateProperties { clearTimer() { const now = new Date().getTime(); - const { timerId, timerApproximateEndingAtMs = now, windowScope } = this; + const { timerId, timerApproximateEndingAtMs = now } = this; if (timerId) { - windowScope.clearTimeout(timerId); + clearTimeout(timerId); clearInterval(this.intervalId!); this.timerId = null; delete this.timerApproximateEndingAtMs; @@ -168,11 +168,8 @@ export class TimedTrackState implements ICommonStateProperties { } setNextStateTimer(timeMs: number) { - this.timerId = this.windowScope.setTimeout( - () => this.setNextState(), - timeMs - ); - this.intervalId = this.windowScope.setInterval( + this.timerId = setTimeout(() => this.setNextState(), timeMs); + this.intervalId = setInterval( () => this.log( `${( diff --git a/src/assetFilters.ts b/src/assetFilters.ts index 39f537ef..c614ae7a 100644 --- a/src/assetFilters.ts +++ b/src/assetFilters.ts @@ -1,14 +1,10 @@ -import distance from "@turf/distance"; import booleanPointInPolygon from "@turf/boolean-point-in-polygon"; +import distance from "@turf/distance"; import { Coord } from "@turf/helpers"; -import { isEmpty } from "./utils"; import { GeoListenMode } from "./mixer"; -import { GeoListenModeType, IMixParams } from "./types"; +import { IMixParams } from "./types"; import { IDecoratedAsset } from "./types/asset"; -import { - InvalidArgumentError, - RoundwareFrameworkError, -} from "./errors/app.errors"; +import { isEmpty } from "./utils"; export interface IAssetPriorities { readonly DISCARD: false; diff --git a/src/events.ts b/src/events.ts index aeb4adca..5441bf5a 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,7 +1,6 @@ import { ApiClient } from "./api-client"; import { IAssetData } from "./types/asset"; import { EventPayload, EventType } from "./types/events"; -type ListenEventsTypes = "play"; const LISTEN_EVENTS = "/listenevents/"; const EVENTS_PATH = `/events/`; /** diff --git a/src/players/SpeakerStreamer.ts b/src/players/SpeakerStreamer.ts index 8d7f6f49..2f460c63 100644 --- a/src/players/SpeakerStreamer.ts +++ b/src/players/SpeakerStreamer.ts @@ -3,10 +3,10 @@ import { IGainNode, IMediaElementAudioSourceNode, } from "standardized-audio-context"; -import { silenceAudioBase64 } from "../playlistAudioTrack"; + import { SpeakerConfig } from "../types/roundware"; import { ISpeakerPlayer, SpeakerConstructor } from "../types/speaker"; -import { cleanAudioURL, speakerLog } from "../utils"; +import { cleanAudioURL, silenceAudioBase64, speakerLog } from "../utils"; /** * diff --git a/src/playlistAudioTrack.ts b/src/playlistAudioTrack.ts index 390c73a8..19c858f0 100644 --- a/src/playlistAudioTrack.ts +++ b/src/playlistAudioTrack.ts @@ -14,6 +14,7 @@ import { getUrlParam, makeAudioSafeToPlay, playlistTrackLog, + silenceAudioBase64, timestamp, } from "./utils"; /* @@ -109,9 +110,6 @@ export const LOGGABLE_HOWL_EVENTS = [ "unlock", ]; -export const silenceAudioBase64 = - "data:audio/mpeg;base64,SUQzBAAAAAABEVRYWFgAAAAtAAADY29tbWVudABCaWdTb3VuZEJhbmsuY29tIC8gTGFTb25vdGhlcXVlLm9yZwBURU5DAAAAHQAAA1N3aXRjaCBQbHVzIMKpIE5DSCBTb2Z0d2FyZQBUSVQyAAAABgAAAzIyMzUAVFNTRQAAAA8AAANMYXZmNTcuODMuMTAwAAAAAAAAAAAAAAD/80DEAAAAA0gAAAAATEFNRTMuMTAwVVVVVVVVVVVVVUxBTUUzLjEwMFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVf/zQsRbAAADSAAAAABVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVf/zQMSkAAADSAAAAABVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV"; - const NEARLY_ZERO = 0.0001; export class PlaylistAudiotrack { /** diff --git a/src/user.ts b/src/user.ts index 5976d48c..ff02823f 100644 --- a/src/user.ts +++ b/src/user.ts @@ -22,7 +22,7 @@ export class User { clientType = "web", }: { apiClient: ApiClient; - deviceId: string; + deviceId?: string; clientType?: string; }) { // TODO need to try to persist deviceId as a random value that can partially serve as "a unique identifier generated by the client" that can diff --git a/src/utils.ts b/src/utils.ts index 8a11970f..523f4639 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,9 +3,12 @@ const { point } = require("@turf/helpers"); import { Point, Feature } from "@turf/helpers"; import { AudioContext, IAudioContext } from "standardized-audio-context"; -import { silenceAudioBase64 } from "./playlistAudioTrack"; + const MATCHES_URI_SCHEME = new RegExp(/^https?:\/\//i); const MATCHES_WAV_FILE = new RegExp(/\.wav$/i); +export const silenceAudioBase64 = + "data:audio/mpeg;base64,SUQzBAAAAAABEVRYWFgAAAAtAAADY29tbWVudABCaWdTb3VuZEJhbmsuY29tIC8gTGFTb25vdGhlcXVlLm9yZwBURU5DAAAAHQAAA1N3aXRjaCBQbHVzIMKpIE5DSCBTb2Z0d2FyZQBUSVQyAAAABgAAAzIyMzUAVFNTRQAAAA8AAANMYXZmNTcuODMuMTAwAAAAAAAAAAAAAAD/80DEAAAAA0gAAAAATEFNRTMuMTAwVVVVVVVVVVVVVUxBTUUzLjEwMFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVf/zQsRbAAADSAAAAABVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVf/zQMSkAAADSAAAAABVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV"; + export const isIos = () => { return ( [ @@ -43,8 +46,8 @@ export const cleanAudioURL = ( * Makes sure coordinates are in range of +180 to -180. * @param {number[]} coordinates */ -const normalizeCoords = (coordinates: number[]) => { - for (let i = 0; i <= coordinates.length; i++) { +export const normalizeCoords = (coordinates: number[]) => { + for (let i = 0; i < coordinates.length; i++) { if (coordinates[i] > 180) coordinates[i] = (coordinates[i] % 180) - 180; else if (coordinates[i] < -180) coordinates[i] = (coordinates[i] % 180) + 180; @@ -106,9 +109,9 @@ export const UNLOCK_AUDIO_EVENTS = [ /** Helps stabilize WebAudio startup @thanks https://www.mattmontag.com/web/unlock-web-audio-in-safari-for-ios-and-macos */ -function unlockAudioContext( - body: Window[`document`][`body`], - audioCtx: AudioContext +export function unlockAudioContext( + body: Pick, + audioCtx: Pick ) { if (audioCtx.state !== "suspended") return; diff --git a/tests/__tests__/TrackStates.test.ts b/tests/__tests__/TrackStates.test.ts index c115b6c5..8e75dae2 100644 --- a/tests/__tests__/TrackStates.test.ts +++ b/tests/__tests__/TrackStates.test.ts @@ -5,9 +5,6 @@ jest.mock("standardized-audio-context", () => require("standardized-audio-context-mock") ); describe("LoadingState", () => { - test("should initialize", () => { - const state = new LoadingState(mockPlaylistAudiotrack, mockTrackOptions); - expect(state).toBeTruthy(); - expect(state.asset).toBeNull(); - }); + const state = new LoadingState(mockPlaylistAudiotrack, mockTrackOptions); + expect(state).toBeTruthy(); }); diff --git a/tests/__tests__/audioPanner.test.ts b/tests/__tests__/audioPanner.test.ts new file mode 100644 index 00000000..df82d98a --- /dev/null +++ b/tests/__tests__/audioPanner.test.ts @@ -0,0 +1,69 @@ +import { IAudioContext, IStereoPannerNode } from "standardized-audio-context"; +import { AudioPanner } from "../../src/audioPanner"; +import { random } from "../../src/utils"; + +jest.mock("../../src/utils"); + +const mockRandom = random as jest.MockedFunction; + +describe("AudioPanner", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test("should instantiate AudioPanner", () => { + const audioContext = {} as IAudioContext; + const panNode = {} as IStereoPannerNode; + + const audioPanner = new AudioPanner(0, 1, 2, 3, panNode, audioContext); + + expect(audioPanner).toBeInstanceOf(AudioPanner); + }); + + test("should update parameters", () => { + const audioContext = {} as IAudioContext; + const panNode = {} as IStereoPannerNode; + const audioPanner = new AudioPanner(0, 1, 2, 3, panNode, audioContext); + + audioPanner.updateParams(); + + expect(mockRandom).toHaveBeenCalledTimes(3); // Called for initial, final, and duration + }); + + test("should start panning", () => { + const audioContext = { + currentTime: 0, + } as IAudioContext; + const panNode = { + pan: { + value: 0, + linearRampToValueAtTime: jest.fn(), + }, + } as unknown as IStereoPannerNode; + + const audioPanner = new AudioPanner(0, 1, 2, 3, panNode, audioContext); + + audioPanner.start(); + + expect(panNode.pan.linearRampToValueAtTime).toHaveBeenCalledWith( + expect.any(Number), + expect.any(Number) + ); + + // Ensure the timer function is called + jest.runOnlyPendingTimers(); + expect(mockRandom).toHaveBeenCalledTimes(3); // Called for initial, final, and duration + }); + + test("should clear timeout", () => { + const audioContext = {} as IAudioContext; + const panNode = {} as IStereoPannerNode; + + const audioPanner = new AudioPanner(0, 1, 2, 3, panNode, audioContext); + + audioPanner.start(); + audioPanner.clear(); + + expect(clearTimeout).toHaveBeenCalled(); + }); +}); diff --git a/tests/__tests__/events.test.ts b/tests/__tests__/events.test.ts new file mode 100644 index 00000000..362a0a24 --- /dev/null +++ b/tests/__tests__/events.test.ts @@ -0,0 +1,118 @@ +// Import necessary dependencies and the RoundwareEvents class + +import { ApiClient } from "../../src/api-client"; +import { RoundwareEvents } from "../../src/events"; +import { EventType, EventPayload } from "../../src/types/events"; + +// Mock the ApiClient class +jest.mock("../../src/api-client"); + +describe("RoundwareEvents", () => { + let roundwareEvents: RoundwareEvents; + let mockApiClient: jest.Mocked; + + beforeEach(() => { + mockApiClient = new ApiClient("") as jest.Mocked; + roundwareEvents = new RoundwareEvents(123, mockApiClient); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("logAssetStart", () => { + it("should log asset start and update _startedAssets", async () => { + const assetId = 1; + const startTime = new Date(); + + // Mock the post method of ApiClient + mockApiClient.post.mockResolvedValue({ id: 456 }); + + await roundwareEvents.logAssetStart(assetId, startTime); + + expect(mockApiClient.post).toHaveBeenCalledWith("/listenevents/", { + starttime: startTime, + session: 123, + asset: assetId, + }); + + expect(roundwareEvents["_startedAssets"]).toHaveProperty( + assetId.toString(), + expect.objectContaining({ startTime, id: 456 }) + ); + }); + + it("should log asset start with default startTime if not provided", async () => { + const assetId = 1; + + // Mock the post method of ApiClient + mockApiClient.post.mockResolvedValue({ id: 456 }); + + await roundwareEvents.logAssetStart(assetId); + + expect(mockApiClient.post).toHaveBeenCalledWith("/listenevents/", { + starttime: expect.any(Date), + session: 123, + asset: assetId, + }); + + expect(roundwareEvents["_startedAssets"]).toHaveProperty( + assetId.toString(), + expect.objectContaining({ startTime: expect.any(Date), id: 456 }) + ); + }); + }); + + describe("logAssetEnd", () => { + it("should log asset end if assetId exists in _startedAssets", async () => { + const assetId = 1; + const startTime = new Date(); + roundwareEvents["_startedAssets"][assetId] = { startTime, id: 789 }; + + // Mock the patch method of ApiClient + mockApiClient.patch.mockResolvedValue({}); + + await roundwareEvents.logAssetEnd(assetId); + + expect(mockApiClient.patch).toHaveBeenCalledWith( + "/listenevents/789", + expect.objectContaining({ + duration_in_seconds: expect.any(Number), + }) + ); + }); + + it("should not log asset end if assetId does not exist in _startedAssets", async () => { + const assetId = 1; + + // Mock the patch method of ApiClient + mockApiClient.patch.mockResolvedValue({}); + + await roundwareEvents.logAssetEnd(assetId); + + expect(mockApiClient.patch).not.toHaveBeenCalled(); + }); + }); + + describe("logEvent", () => { + it("should log an event using the post method of ApiClient", async () => { + const eventType: EventType = "start_session"; + const payload: EventPayload = { + latitude: 40.7128, + longitude: -74.006, + }; + + // Mock the post method of ApiClient + mockApiClient.post.mockResolvedValue(payload); + + await roundwareEvents.logEvent(eventType, payload); + + expect(mockApiClient.post).toHaveBeenCalledWith("/events/", { + session_id: 123, + event_type: eventType, + client_time: expect.any(String), + ...payload, + }); + }); + }); +}); diff --git a/tests/__tests__/mixer/AssetEnvelope.test.ts b/tests/__tests__/mixer/AssetEnvelope.test.ts new file mode 100644 index 00000000..e69de29b diff --git a/tests/__tests__/players/SpeakerPrefetchPlayer.test.ts b/tests/__tests__/players/SpeakerPrefetchPlayer.test.ts new file mode 100644 index 00000000..e69de29b diff --git a/tests/__tests__/session.test.ts b/tests/__tests__/session.test.ts new file mode 100644 index 00000000..c692b8d6 --- /dev/null +++ b/tests/__tests__/session.test.ts @@ -0,0 +1,76 @@ +import ApiClient from "../../src/api-client"; +import { Session } from "../../src/session"; + +// Mock the ApiClient class +jest.mock("../../src/api-client"); + +describe("Session", () => { + let session: Session; + let mockApiClient: jest.Mocked; + + beforeEach(() => { + mockApiClient = new ApiClient("") as jest.Mocked; + session = new Session(window.navigator, 789, true, { + apiClient: mockApiClient, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("constructor", () => { + it("should truncate userAgent string if it is longer than 127 characters", () => { + const longUserAgent = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36".repeat( + 5 + ); // Make it longer than 127 characters + + const mockApiClient = new ApiClient("") as jest.Mocked; + const session = new Session( + { userAgent: longUserAgent } as any, + 789, + true, + { + apiClient: mockApiClient, + } + ); + + expect(session["clientSystem"].length).toEqual(127); + }); + }); + + describe("toString", () => { + it("should return a human-readable representation of the session", () => { + const result = session.toString(); + expect(result).toEqual("Roundware Session #undefined"); + }); + }); + + describe("connect", () => { + it("should connect to the server and set sessionId on success", async () => { + const responseData = { id: 123 }; + + // Mock the post method of ApiClient + mockApiClient.post.mockResolvedValue(responseData); + + const result = await session.connect(); + + expect(mockApiClient.post).toHaveBeenCalledWith("/sessions/", { + project_id: 789, + geo_listen_enabled: true, + client_system: expect.any(String), + }); + + expect(result).toEqual(123); + expect(session.sessionId).toEqual(123); + }); + + it("should handle API call failure and throw an error", async () => { + // Mock the post method of ApiClient to throw an error + mockApiClient.post.mockRejectedValue(new Error("API call failure")); + + await expect(session.connect()).rejects.toThrowError("API call failure"); + }); + }); +}); diff --git a/tests/__tests__/speaker.test.ts b/tests/__tests__/speaker.test.ts new file mode 100644 index 00000000..b9450d4f --- /dev/null +++ b/tests/__tests__/speaker.test.ts @@ -0,0 +1,60 @@ +import { Speaker } from "../../src/speaker"; +import { ApiClient } from "../../src/api-client"; +import { ISpeakerData } from "../../src/types/speaker"; + +// Mock the ApiClient class +jest.mock("../../src/api-client"); + +describe("Speaker", () => { + let speaker: Speaker; + let mockApiClient: jest.Mocked; + + beforeEach(() => { + mockApiClient = new ApiClient("") as jest.Mocked; + speaker = new Speaker(789, { apiClient: mockApiClient }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("toString", () => { + it("should return a string representation of Speaker", () => { + const result = speaker.toString(); + expect(result).toEqual("Roundware Speaker (#789)"); + }); + }); + + describe("connect", () => { + it("should connect to speakers and return the result", async () => { + const responseData: ISpeakerData[] = [ + { + id: 1, + maxvolume: 100, + minvolume: 20, + attenuation_distance: 50, + uri: "https://example.com/audio1.mp3", + }, + { + id: 2, + maxvolume: 90, + minvolume: 30, + attenuation_distance: 60, + uri: "https://example.com/audio2.mp3", + }, + ]; + + // Mock the get method of ApiClient + mockApiClient.get.mockResolvedValue(responseData); + + const result = await speaker.connect({ someOption: "value" }); + + expect(mockApiClient.get).toHaveBeenCalledWith("/speakers/", { + someOption: "value", + project_id: 789, + }); + + expect(result).toEqual(responseData); + }); + }); +}); diff --git a/tests/__tests__/timed_asset.test.ts b/tests/__tests__/timed_asset.test.ts new file mode 100644 index 00000000..e2f85368 --- /dev/null +++ b/tests/__tests__/timed_asset.test.ts @@ -0,0 +1,48 @@ +import { TimedAsset } from "../../src/timed_asset"; +import { ApiClient } from "../../src/api-client"; +import { ITimedAssetData } from "../../src/types"; + +// Mock the ApiClient class +jest.mock("../../src/api-client"); + +describe("TimedAsset", () => { + let timedAsset: TimedAsset; + let mockApiClient: jest.Mocked; + + beforeEach(() => { + mockApiClient = new ApiClient("") as jest.Mocked; + timedAsset = new TimedAsset(456, { apiClient: mockApiClient }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("toString", () => { + it("should return a string representation of TimedAssets", () => { + const result = timedAsset.toString(); + expect(result).toEqual("Roundware TimedAssets (#456)"); + }); + }); + + describe("connect", () => { + it("should connect to TimedAssets and return the result", async () => { + const responseData: ITimedAssetData[] = [ + { asset_id: 1, start: 123, end: 456 }, + { asset_id: 2, start: 789, end: 987 }, + ]; + + // Mock the get method of ApiClient + mockApiClient.get.mockResolvedValue(responseData); + + const result = await timedAsset.connect({ someOption: "value" }); + + expect(mockApiClient.get).toHaveBeenCalledWith("/timedassets/", { + someOption: "value", + project_id: 456, + }); + + expect(result).toEqual(responseData); + }); + }); +}); diff --git a/tests/__tests__/user.test.ts b/tests/__tests__/user.test.ts new file mode 100644 index 00000000..ed422c85 --- /dev/null +++ b/tests/__tests__/user.test.ts @@ -0,0 +1,106 @@ +import { User } from "../../src/user"; +import { ApiClient } from "../../src/api-client"; + +// Mock the ApiClient class +jest.mock("../../src/api-client"); + +describe("User", () => { + let user: User; + let mockApiClient: jest.Mocked; + + beforeEach(() => { + mockApiClient = new ApiClient("") as jest.Mocked; + user = new User({ + apiClient: mockApiClient, + deviceId: "00000000000000", + clientType: "web", + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("constructor", () => { + it("should set default deviceId if not provided", () => { + const mockApiClient = new ApiClient("") as jest.Mocked; + const user = new User({ + apiClient: mockApiClient, + clientType: "web", + }); + + expect(user.deviceId).toEqual("00000000000000"); + }); + }); + + describe("toString", () => { + it("should return a human-readable representation of the user", () => { + const result = user.toString(); + expect(result).toEqual("User (unknown) (deviceId 00000000000000)"); + }); + }); + + describe("connect", () => { + it("should connect the user and update properties on success", async () => { + const responseData = { + username: "testuser", + token: "abc123", + id: 123, + }; + + // Mock the post method of ApiClient + mockApiClient.post.mockResolvedValue(responseData); + + await user.connect(); + + expect(mockApiClient.post).toHaveBeenCalledWith("/users/", { + device_id: "00000000000000", + client_type: "web", + }); + + expect(user.userName).toEqual("testuser"); + expect(user.apiClient.authToken).toEqual("abc123"); + expect(user.id).toEqual(123); + }); + + it("should handle auth failure and return an empty object", async () => { + // Mock the post method of ApiClient to throw an error + mockApiClient.post.mockRejectedValue(new Error("Auth failure")); + + const result = await user.connect(); + + expect(result).toEqual({}); + }); + }); + + describe("updateUser", () => { + it("should update the user's information", async () => { + const partialUserData = { + first_name: "John", + last_name: "Doe", + }; + + const responseData = { + username: "testuser", + token: "abc123", + id: 123, + first_name: "John", + last_name: "Doe", + email: "john.doe@example.com", + device_id: "00000000000000", + client_type: "web", + }; + + // Mock the patch method of ApiClient + mockApiClient.patch.mockResolvedValue(responseData); + + const result = await user.updateUser(partialUserData); + + expect(mockApiClient.patch).toHaveBeenCalledWith( + "/users/undefined", + partialUserData + ); + expect(result).toEqual(responseData); + }); + }); +}); diff --git a/tests/__tests__/utils.test.ts b/tests/__tests__/utils.test.ts new file mode 100644 index 00000000..d07e52b0 --- /dev/null +++ b/tests/__tests__/utils.test.ts @@ -0,0 +1,163 @@ +// __tests__/utils.test.ts +import { AudioContext } from "standardized-audio-context-mock"; +import { + cleanAudioURL, + coordsToPoints, + random, + randomInt, + unlockAudioContext, + isIos, + normalizeCoords, + isEmpty, + hasOwnProperty, + NEARLY_ZERO, + UNLOCK_AUDIO_EVENTS, + buildAudioContext, +} from "../../src/utils"; // Update this path based on your project structure + +describe("cleanAudioURL", () => { + it("should clean audio URL and replace .wav with .mp3", () => { + const result = cleanAudioURL("//example.com/audio/test.wav"); + expect(result).toEqual("//example.com/audio/test.mp3"); + }); + + it("should clean audio URL and replace .wav with .m4a on iOS", () => { + jest.spyOn(global.navigator, "platform", "get").mockReturnValue("iPhone"); + const result = cleanAudioURL("//example.com/audio/test.wav", true); + expect(result).toEqual("//example.com/audio/test.m4a"); + }); +}); + +describe("coordsToPoints", () => { + it("should convert coordinates to points", () => { + const result = coordsToPoints({ latitude: 40, longitude: -75 }); + expect(result.geometry.coordinates).toEqual([-75, 40]); + }); +}); + +describe("random", () => { + it("should generate a random number between given range", () => { + const result = random(5, 10); + expect(result).toBeGreaterThanOrEqual(5); + expect(result).toBeLessThanOrEqual(10); + }); +}); + +describe("randomInt", () => { + it("should generate a random integer between given range", () => { + const result = randomInt(5, 10); + expect(result).toBeGreaterThanOrEqual(5); + expect(result).toBeLessThanOrEqual(10); + }); +}); + +describe("buildAudioContext", () => { + class MockAudioContext { + state = "suspended"; + resume = jest.fn(); + } + + beforeEach(() => { + // Add AudioContext to the global window object + (global as any).window = { + AudioContext: MockAudioContext, + document: { + body: { + addEventListener: jest.fn(), + }, + }, + }; + + // Reset mocks before each test + jest.clearAllMocks(); + }); + + it("should create an AudioContext instance", () => { + const audioContext = buildAudioContext(); + expect(audioContext).toBeInstanceOf(MockAudioContext); + }); + + it("should call unlockAudioContext with body and audioContext", () => { + const audioContext = buildAudioContext(); + expect( + (global as any).window.document.body.addEventListener + ).toHaveBeenCalledWith(expect.any(String), expect.any(Function), { + once: true, + }); + expect(audioContext.resume).toHaveBeenCalled(); + }); + + it("should set up onstatechange event handler", () => { + const audioContext = buildAudioContext(); + // Simulate the onstatechange event with a mock Event object + const mockEvent = new Event("statechange"); + + // Check if audioContext is not null before calling onstatechange + if (audioContext) { + audioContext.onstatechange?.(mockEvent); + expect(console.info).toHaveBeenCalledWith( + `[Audio Context]: ${audioContext.state}` + ); + } else { + fail("audioContext is null"); + } + }); + + // After all tests, clean up the global object + afterAll(() => { + delete (global as any).window; + }); +}); + +describe("isIos", () => { + it("should return true on iOS platform", () => { + jest.spyOn(global.navigator, "platform", "get").mockReturnValue("iPhone"); + const result = isIos(); + expect(result).toBe(true); + }); + + it("should return false on non-iOS platform", () => { + jest.spyOn(global.navigator, "platform", "get").mockReturnValue("Windows"); + const result = isIos(); + expect(result).toBe(false); + }); +}); + +describe("normalizeCoords", () => { + it("should normalize coordinates within the range", () => { + const result = normalizeCoords([190, -190, 200]); + expect(result).toEqual([-170, 170, -160]); + }); +}); + +describe("isEmpty", () => { + it("should return true for an empty array", () => { + const result = isEmpty([]); + expect(result).toBe(true); + }); + + it("should return false for a non-empty array", () => { + const result = isEmpty([1, 2, 3]); + expect(result).toBe(false); + }); +}); + +describe("hasOwnProperty", () => { + it("should return true if object has the property", () => { + const obj = { key: "value" }; + const result = hasOwnProperty(obj, "key"); + expect(result).toBe(true); + }); + + it("should return false if object does not have the property", () => { + const obj = { key: "value" }; + const result = hasOwnProperty(obj, "otherKey"); + expect(result).toBe(false); + }); +}); + +describe("NEARLY_ZERO", () => { + it("should be a very small number", () => { + expect(NEARLY_ZERO).toBeCloseTo(0, 3); + }); +});