diff --git a/.env.example b/.env.example
index 2e0ac7f..bcf9bdc 100644
--- a/.env.example
+++ b/.env.example
@@ -7,3 +7,5 @@ APNS_BUNDLE_ID=
# base64-encoded APNs private key
APNS_PRIVATE_KEY=
+
+PARKING_LOGGING_INTERVAL_MS=
diff --git a/.gitignore b/.gitignore
index 496b53a..5d63b40 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,6 @@ yarn-error.log*
# Keys
private/
+
+# JetBrains
+.idea
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 13566b8..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Editor-based HTTP Client requests
-/httpRequests/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
deleted file mode 100644
index a55e7a1..0000000
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index cb83045..0000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index b5faada..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/project-inter-server.iml b/.idea/project-inter-server.iml
deleted file mode 100644
index 24643cc..0000000
--- a/.idea/project-inter-server.iml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 94a25f7..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index 643118a..c438853 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -93,5 +93,11 @@ Currently supports Chapman University (Passio System ID: "263"). Each university
## Development Guidelines
+### General Guidelines
+- Use test-driven development. Always write tests before implementation, and run them before and after implementation.
+
### Git Workflow
-- Use the name of the branch for all pull requests
\ No newline at end of file
+- Use the name of the branch for all pull requests
+
+### Code Style
+- Prefer arrow functions, especially in classes
diff --git a/docker-compose.yml b/docker-compose.yml
index e10842b..5d21e58 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,6 +10,7 @@ x-common-environment: &common-server-environment
APNS_TEAM_ID: ${APNS_TEAM_ID}
APNS_KEY_ID: ${APNS_KEY_ID}
APNS_PRIVATE_KEY: ${APNS_PRIVATE_KEY}
+ PARKING_LOGGING_INTERVAL_MS: ${PARKING_LOGGING_INTERVAL_MS}
REDIS_URL: redis://redis:6379
services:
@@ -50,15 +51,21 @@ services:
- .:/usr/src/app
redis:
- image: redis:alpine
+ image: redis/redis-stack:7.2.0-v17
+ container_name: redis-timeseries
ports:
- "6379:6379"
volumes:
- - ./redis.conf:/usr/local/etc/redis/redis.conf
- command: redis-server /usr/local/etc/redis/redis.conf
+ - redis_data:/data
+ - ./redis-stack.conf:/redis-stack.conf
+ command: redis-stack-server /redis-stack.conf
redis-no-persistence:
- image: redis:alpine
+ image: redis/redis-stack:7.2.0-v17
+ container_name: redis-timeseries-no-persistence
ports:
- "6379:6379"
+volumes:
+ redis_data: # Add this volume definition
+
diff --git a/redis.conf b/redis-stack.conf
similarity index 100%
rename from redis.conf
rename to redis-stack.conf
diff --git a/src/entities/InterchangeSystem.ts b/src/entities/InterchangeSystem.ts
index 37aed58..af9faed 100644
--- a/src/entities/InterchangeSystem.ts
+++ b/src/entities/InterchangeSystem.ts
@@ -13,6 +13,7 @@ import {
buildParkingRepositoryLoaderIfExists,
ParkingRepositoryLoaderBuilderArguments
} from "../loaders/parking/buildParkingRepositoryLoaderIfExists";
+import { RedisParkingRepository } from "../repositories/RedisParkingRepository";
export interface InterchangeSystemBuilderArguments {
name: string;
@@ -75,7 +76,7 @@ export class InterchangeSystem {
);
notificationScheduler.startListeningForUpdates();
- let { parkingRepository, timedParkingLoader } = this.buildParkingLoaderAndRepository(args.parkingSystemId);
+ let { parkingRepository, timedParkingLoader } = await this.buildRedisParkingLoaderAndRepository(args.parkingSystemId);
timedParkingLoader?.start();
return new InterchangeSystem(
@@ -120,8 +121,8 @@ export class InterchangeSystem {
);
notificationScheduler.startListeningForUpdates();
- let { parkingRepository, timedParkingLoader } = this.buildParkingLoaderAndRepository(args.parkingSystemId);
- // Timed parking loader is not started
+ let { parkingRepository, timedParkingLoader } = this.buildInMemoryParkingLoaderAndRepository(args.parkingSystemId);
+ // Timed parking loader is not started here
return new InterchangeSystem(
args.name,
@@ -135,7 +136,32 @@ export class InterchangeSystem {
);
}
- private static buildParkingLoaderAndRepository(id?: string) {
+ private static async buildRedisParkingLoaderAndRepository(id?: string) {
+ if (id === undefined) {
+ return { parkingRepository: null, timedParkingLoader: null };
+ }
+
+ let parkingRepository: RedisParkingRepository | null = new RedisParkingRepository();
+ await parkingRepository.connect();
+
+ const loaderBuilderArguments: ParkingRepositoryLoaderBuilderArguments = {
+ id,
+ repository: parkingRepository,
+ };
+ let parkingLoader = buildParkingRepositoryLoaderIfExists(
+ loaderBuilderArguments,
+ );
+
+ let timedParkingLoader = null;
+ if (parkingLoader == null) {
+ parkingRepository = null;
+ } else {
+ timedParkingLoader = new TimedApiBasedRepositoryLoader(parkingLoader);
+ }
+ return { parkingRepository, timedParkingLoader };
+ }
+
+ private static buildInMemoryParkingLoaderAndRepository(id?: string) {
if (id === undefined) {
return { parkingRepository: null, timedParkingLoader: null };
}
diff --git a/src/entities/ParkingRepositoryEntities.ts b/src/entities/ParkingRepositoryEntities.ts
index b78a754..36ca488 100644
--- a/src/entities/ParkingRepositoryEntities.ts
+++ b/src/entities/ParkingRepositoryEntities.ts
@@ -8,4 +8,10 @@ export interface IParkingStructure extends IEntityWithTimestamp, IEntityWithId {
name: string;
}
+export interface IParkingStructureTimestampRecord {
+ timestampMs: number;
+ id: string;
+ spotsAvailable: number;
+}
+
// In the future, add support for viewing different levels of the structure
diff --git a/src/repositories/BaseRedisRepository.ts b/src/repositories/BaseRedisRepository.ts
new file mode 100644
index 0000000..4793d0a
--- /dev/null
+++ b/src/repositories/BaseRedisRepository.ts
@@ -0,0 +1,33 @@
+import { createClient } from 'redis';
+
+export abstract class BaseRedisRepository {
+ protected redisClient;
+
+ constructor(
+ redisClient = createClient({
+ url: process.env.REDIS_URL,
+ socket: {
+ tls: (process.env.REDIS_URL?.match(/rediss:/) != null),
+ rejectUnauthorized: false,
+ }
+ }),
+ ) {
+ this.redisClient = redisClient;
+ }
+
+ get isReady() {
+ return this.redisClient.isReady;
+ }
+
+ public async connect() {
+ await this.redisClient.connect();
+ }
+
+ public async disconnect() {
+ await this.redisClient.disconnect();
+ }
+
+ public async clearAllData() {
+ await this.redisClient.flushAll();
+ }
+}
\ No newline at end of file
diff --git a/src/repositories/InMemoryParkingRepository.ts b/src/repositories/InMemoryParkingRepository.ts
index 763ab38..741cfd9 100644
--- a/src/repositories/InMemoryParkingRepository.ts
+++ b/src/repositories/InMemoryParkingRepository.ts
@@ -1,36 +1,160 @@
import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository";
-import { IParkingStructure } from "../entities/ParkingRepositoryEntities";
+import {
+ IParkingStructure,
+ IParkingStructureTimestampRecord
+} from "../entities/ParkingRepositoryEntities";
+import { HistoricalParkingAverageQueryResult, ParkingStructureCountOptions } from "./ParkingGetterRepository";
+import { CircularQueue } from "../types/CircularQueue";
+import { PARKING_LOGGING_INTERVAL_MS } from "./ParkingRepositoryConstants";
+
+// If every 10 minutes, two weeks of data (6x per hour * 24x per day * 7x per week * 2)
+export const MAX_NUM_ENTRIES = 2016;
+
+export type ParkingStructureID = string;
export class InMemoryParkingRepository implements ParkingGetterSetterRepository {
- private structures: Map;
+ private dataLastAdded: Map = new Map();
+ private loggingIntervalMs = PARKING_LOGGING_INTERVAL_MS;
- constructor() {
- this.structures = new Map();
+ constructor(
+ private structures: Map = new Map(),
+ private historicalData: Map> = new Map(),
+ ) {
}
- async addOrUpdateParkingStructure(structure: IParkingStructure): Promise {
+ addOrUpdateParkingStructure = async (structure: IParkingStructure): Promise => {
this.structures.set(structure.id, { ...structure });
- }
+ await this.addHistoricalDataForStructure(structure);
+ };
- async clearParkingStructureData(): Promise {
+ private addHistoricalDataForStructure = async (structure: IParkingStructure): Promise => {
+ const now = Date.now();
+ const lastAdded = this.dataLastAdded.get(structure.id);
+
+ if (this.shouldLogHistoricalData(lastAdded, now)) {
+ const timestampRecord = this.createTimestampRecord(structure, now);
+ this.ensureHistoricalDataExists(structure.id);
+ this.addRecordToHistoricalData(structure.id, timestampRecord);
+ this.dataLastAdded.set(structure.id, new Date(now));
+ }
+ };
+
+ clearParkingStructureData = async (): Promise => {
this.structures.clear();
- }
+ this.historicalData.clear();
+ this.dataLastAdded.clear();
+ };
- async getParkingStructureById(id: string): Promise {
+ getParkingStructureById = async (id: string): Promise => {
const structure = this.structures.get(id);
return structure ? { ...structure } : null;
- }
+ };
- async getParkingStructures(): Promise {
- return Array.from(this.structures.values()).map(structure => ({ ...structure }));
- }
+ getParkingStructures = async (): Promise => Array.from(this.structures.values()).map(structure => ({...structure}));
- async removeParkingStructureIfExists(id: string): Promise {
+ removeParkingStructureIfExists = async (id: string): Promise => {
const structure = this.structures.get(id);
if (structure) {
this.structures.delete(id);
+ this.historicalData.delete(id);
+ this.dataLastAdded.delete(id);
return { ...structure };
}
return null;
- }
+ };
+
+ getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: ParkingStructureCountOptions): Promise => {
+ const queue = this.historicalData.get(id);
+ if (!queue || queue.size() === 0) {
+ return [];
+ }
+
+ const records = this.extractRecordsFromQueue(queue);
+ return this.calculateAveragesFromRecords(records, options);
+ };
+
+ private shouldLogHistoricalData = (lastAdded: Date | undefined, currentTime: number): boolean => {
+ return !lastAdded || (currentTime - lastAdded.getTime()) >= this.loggingIntervalMs;
+ };
+
+ private createTimestampRecord = (structure: IParkingStructure, timestampMs: number): IParkingStructureTimestampRecord => ({
+ id: structure.id,
+ spotsAvailable: structure.spotsAvailable,
+ timestampMs,
+ });
+
+ private ensureHistoricalDataExists = (structureId: string): void => {
+ if (!this.historicalData.has(structureId)) {
+ this.historicalData.set(structureId, new CircularQueue(MAX_NUM_ENTRIES));
+ }
+ };
+
+ private addRecordToHistoricalData = (structureId: string, record: IParkingStructureTimestampRecord): void => {
+ const sortingCallback = (a: IParkingStructureTimestampRecord, b: IParkingStructureTimestampRecord) => a.timestampMs - b.timestampMs;
+ this.historicalData.get(structureId)?.appendWithSorting(record, sortingCallback);
+ };
+
+ private extractRecordsFromQueue = (queue: CircularQueue): IParkingStructureTimestampRecord[] => {
+ const records: IParkingStructureTimestampRecord[] = [];
+ for (let i = 0; i < queue.size(); i++) {
+ const record = queue.get(i);
+ if (record) {
+ records.push(record);
+ }
+ }
+ return records;
+ };
+
+ private calculateAveragesFromRecords = (
+ records: IParkingStructureTimestampRecord[],
+ options: ParkingStructureCountOptions
+ ): HistoricalParkingAverageQueryResult[] => {
+ const results: HistoricalParkingAverageQueryResult[] = [];
+ const { startUnixEpochMs, endUnixEpochMs, intervalMs } = options;
+
+ let currentIntervalStart = startUnixEpochMs;
+
+ while (currentIntervalStart < endUnixEpochMs) {
+ const currentIntervalEnd = Math.min(currentIntervalStart + intervalMs, endUnixEpochMs);
+ const recordsInInterval = this.getRecordsInTimeRange(records, currentIntervalStart, currentIntervalEnd);
+
+ if (recordsInInterval.length > 0) {
+ const averageResult = this.calculateAverageForInterval(currentIntervalStart, currentIntervalEnd, recordsInInterval);
+ results.push(averageResult);
+ }
+
+ currentIntervalStart = currentIntervalEnd;
+ }
+
+ return results;
+ };
+
+ private getRecordsInTimeRange = (
+ records: IParkingStructureTimestampRecord[],
+ startMs: number,
+ endMs: number
+ ): IParkingStructureTimestampRecord[] => {
+ return records.filter(record =>
+ record.timestampMs >= startMs && record.timestampMs < endMs
+ );
+ };
+
+ private calculateAverageForInterval = (
+ fromMs: number,
+ toMs: number,
+ records: IParkingStructureTimestampRecord[]
+ ): HistoricalParkingAverageQueryResult => {
+ const totalSpotsAvailable = records.reduce((sum, record) => sum + record.spotsAvailable, 0);
+ const averageSpotsAvailable = totalSpotsAvailable / records.length;
+
+ return {
+ fromUnixEpochMs: fromMs,
+ toUnixEpochMs: toMs,
+ averageSpotsAvailable
+ };
+ };
+
+ setLoggingInterval = (intervalMs: number): void => {
+ this.loggingIntervalMs = intervalMs;
+ };
}
diff --git a/src/repositories/ParkingGetterRepository.ts b/src/repositories/ParkingGetterRepository.ts
index 0e7e00c..8ef13c9 100644
--- a/src/repositories/ParkingGetterRepository.ts
+++ b/src/repositories/ParkingGetterRepository.ts
@@ -1,6 +1,26 @@
import { IParkingStructure } from "../entities/ParkingRepositoryEntities";
+export interface ParkingStructureCountOptions {
+ startUnixEpochMs: number;
+ endUnixEpochMs: number;
+ intervalMs: number;
+}
+
+export interface HistoricalParkingAverageQueryResult {
+ fromUnixEpochMs: number;
+ toUnixEpochMs: number;
+ averageSpotsAvailable: number;
+}
+
+
export interface ParkingGetterRepository {
getParkingStructures(): Promise;
getParkingStructureById(id: string): Promise;
+
+ /**
+ * Get historical averages of parking structure data using the filtering options.
+ * @param id
+ * @param options
+ */
+ getHistoricalAveragesOfParkingStructureCounts(id: string, options: ParkingStructureCountOptions): Promise;
}
diff --git a/src/repositories/ParkingGetterSetterRepository.ts b/src/repositories/ParkingGetterSetterRepository.ts
index cfe23ba..63b60e5 100644
--- a/src/repositories/ParkingGetterSetterRepository.ts
+++ b/src/repositories/ParkingGetterSetterRepository.ts
@@ -7,4 +7,6 @@ export interface ParkingGetterSetterRepository extends ParkingGetterRepository {
removeParkingStructureIfExists(id: string): Promise;
clearParkingStructureData(): Promise;
+
+ setLoggingInterval(intervalMs: number): void;
}
diff --git a/src/repositories/ParkingRepositoryConstants.ts b/src/repositories/ParkingRepositoryConstants.ts
new file mode 100644
index 0000000..4d8b30e
--- /dev/null
+++ b/src/repositories/ParkingRepositoryConstants.ts
@@ -0,0 +1,3 @@
+export const PARKING_LOGGING_INTERVAL_MS = process.env.PARKING_LOGGING_INTERVAL_MS
+ ? parseInt(process.env.PARKING_LOGGING_INTERVAL_MS)
+ : 600000; // Every 10 minutes
diff --git a/src/repositories/RedisNotificationRepository.ts b/src/repositories/RedisNotificationRepository.ts
index ad189bf..6648642 100644
--- a/src/repositories/RedisNotificationRepository.ts
+++ b/src/repositories/RedisNotificationRepository.ts
@@ -6,52 +6,18 @@ import {
NotificationRepository,
ScheduledNotification
} from "./NotificationRepository";
-import { createClient } from "redis";
+import { BaseRedisRepository } from "./BaseRedisRepository";
-export class RedisNotificationRepository implements NotificationRepository {
+export class RedisNotificationRepository extends BaseRedisRepository implements NotificationRepository {
private listeners: Listener[] = [];
private readonly NOTIFICATION_KEY_PREFIX = 'notification:';
- constructor(
- private redisClient = createClient({
- url: process.env.REDIS_URL,
- socket: {
- tls: (process.env.REDIS_URL?.match(/rediss:/) != null),
- rejectUnauthorized: false,
- }
- }),
- ) {
- this.getAllNotificationsForShuttleAndStopId = this.getAllNotificationsForShuttleAndStopId.bind(this);
- this.getSecondsThresholdForNotificationIfExists = this.getSecondsThresholdForNotificationIfExists.bind(this);
- this.deleteNotificationIfExists = this.deleteNotificationIfExists.bind(this);
- this.addOrUpdateNotification = this.addOrUpdateNotification.bind(this);
- this.isNotificationScheduled = this.isNotificationScheduled.bind(this);
- this.subscribeToNotificationChanges = this.subscribeToNotificationChanges.bind(this);
- this.unsubscribeFromNotificationChanges = this.unsubscribeFromNotificationChanges.bind(this);
- }
-
- get isReady() {
- return this.redisClient.isReady;
- }
-
- public async connect() {
- await this.redisClient.connect();
- }
-
- public async disconnect() {
- await this.redisClient.disconnect();
- }
-
- public async clearAllData() {
- await this.redisClient.flushAll();
- }
-
- private getNotificationKey(shuttleId: string, stopId: string): string {
+ private getNotificationKey = (shuttleId: string, stopId: string): string => {
const tuple = new TupleKey(shuttleId, stopId);
return `${this.NOTIFICATION_KEY_PREFIX}${tuple.toString()}`;
- }
+ };
- public async addOrUpdateNotification(notification: ScheduledNotification): Promise {
+ public addOrUpdateNotification = async (notification: ScheduledNotification): Promise => {
const { shuttleId, stopId, deviceId, secondsThreshold } = notification;
const key = this.getNotificationKey(shuttleId, stopId);
@@ -64,9 +30,9 @@ export class RedisNotificationRepository implements NotificationRepository {
};
listener(event);
});
- }
+ };
- public async deleteNotificationIfExists(lookupArguments: NotificationLookupArguments): Promise {
+ public deleteNotificationIfExists = async (lookupArguments: NotificationLookupArguments): Promise => {
const { shuttleId, stopId, deviceId } = lookupArguments;
const key = this.getNotificationKey(shuttleId, stopId);
@@ -93,12 +59,12 @@ export class RedisNotificationRepository implements NotificationRepository {
listener(event);
});
}
- }
+ };
- public async getAllNotificationsForShuttleAndStopId(
+ public getAllNotificationsForShuttleAndStopId = async (
shuttleId: string,
stopId: string
- ): Promise {
+ ): Promise => {
const key = this.getNotificationKey(shuttleId, stopId);
const allNotifications = await this.redisClient.hGetAll(key);
@@ -108,40 +74,40 @@ export class RedisNotificationRepository implements NotificationRepository {
deviceId,
secondsThreshold: parseInt(secondsThreshold)
}));
- }
+ };
- public async getSecondsThresholdForNotificationIfExists(
+ public getSecondsThresholdForNotificationIfExists = async (
lookupArguments: NotificationLookupArguments
- ): Promise {
+ ): Promise => {
const { shuttleId, stopId, deviceId } = lookupArguments;
const key = this.getNotificationKey(shuttleId, stopId);
const threshold = await this.redisClient.hGet(key, deviceId);
return threshold ? parseInt(threshold) : null;
- }
+ };
- public async isNotificationScheduled(
+ public isNotificationScheduled = async (
lookupArguments: NotificationLookupArguments
- ): Promise {
+ ): Promise => {
const threshold = await this.getSecondsThresholdForNotificationIfExists(lookupArguments);
return threshold !== null;
- }
+ };
- public subscribeToNotificationChanges(listener: Listener): void {
+ public subscribeToNotificationChanges = (listener: Listener): void => {
const index = this.listeners.findIndex(
(existingListener) => existingListener === listener
);
if (index < 0) {
this.listeners.push(listener);
}
- }
+ };
- public unsubscribeFromNotificationChanges(listener: Listener): void {
+ public unsubscribeFromNotificationChanges = (listener: Listener): void => {
const index = this.listeners.findIndex(
(existingListener) => existingListener === listener
);
if (index >= 0) {
this.listeners.splice(index, 1);
}
- }
+ };
}
diff --git a/src/repositories/RedisParkingRepository.ts b/src/repositories/RedisParkingRepository.ts
new file mode 100644
index 0000000..fae968c
--- /dev/null
+++ b/src/repositories/RedisParkingRepository.ts
@@ -0,0 +1,203 @@
+import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository";
+import { IParkingStructure } from "../entities/ParkingRepositoryEntities";
+import { HistoricalParkingAverageQueryResult, ParkingStructureCountOptions } from "./ParkingGetterRepository";
+import { BaseRedisRepository } from "./BaseRedisRepository";
+import { PARKING_LOGGING_INTERVAL_MS } from "./ParkingRepositoryConstants";
+
+export type ParkingStructureID = string;
+
+export class RedisParkingRepository extends BaseRedisRepository implements ParkingGetterSetterRepository {
+ private dataLastAdded: Map = new Map();
+ private loggingIntervalMs = PARKING_LOGGING_INTERVAL_MS;
+
+ addOrUpdateParkingStructure = async (structure: IParkingStructure): Promise => {
+ const keys = this.createRedisKeys(structure.id);
+ await this.redisClient.hSet(keys.structure, this.createRedisHashFromStructure(structure));
+ await this.addHistoricalDataForStructure(structure);
+ };
+
+ private addHistoricalDataForStructure = async (structure: IParkingStructure): Promise => {
+ const now = Date.now();
+ const lastAdded = this.dataLastAdded.get(structure.id);
+
+ if (this.shouldLogHistoricalData(lastAdded, now)) {
+ const keys = this.createRedisKeys(structure.id);
+ await this.addTimeSeriesDataPoint(keys.timeSeries, now, structure.spotsAvailable, structure.id);
+ this.dataLastAdded.set(structure.id, new Date(now));
+ }
+ };
+
+ clearParkingStructureData = async (): Promise => {
+ const structureKeys = await this.redisClient.keys('parking:structure:*');
+ const timeSeriesKeys = await this.redisClient.keys('parking:timeseries:*');
+
+ const allKeys = [...structureKeys, ...timeSeriesKeys];
+ if (allKeys.length > 0) {
+ await this.redisClient.del(allKeys);
+ }
+
+ this.dataLastAdded.clear();
+ };
+
+ getParkingStructureById = async (id: string): Promise => {
+ const keys = this.createRedisKeys(id);
+ const data = await this.redisClient.hGetAll(keys.structure);
+
+ if (Object.keys(data).length === 0) {
+ return null;
+ }
+
+ return this.createStructureFromRedisData(data);
+ };
+
+ getParkingStructures = async (): Promise => {
+ const keys = await this.redisClient.keys('parking:structure:*');
+ const structures: IParkingStructure[] = [];
+
+ for (const key of keys) {
+ const data = await this.redisClient.hGetAll(key);
+ if (Object.keys(data).length > 0) {
+ structures.push(this.createStructureFromRedisData(data));
+ }
+ }
+
+ return structures;
+ };
+
+ removeParkingStructureIfExists = async (id: string): Promise => {
+ const structure = await this.getParkingStructureById(id);
+ if (structure) {
+ const keys = this.createRedisKeys(id);
+ await this.redisClient.del([keys.structure, keys.timeSeries]);
+ this.dataLastAdded.delete(id);
+ return structure;
+ }
+ return null;
+ };
+
+ getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: ParkingStructureCountOptions): Promise => {
+ return this.calculateAveragesFromRecords(id, options);
+ };
+
+ private createRedisKeys = (structureId: string) => ({
+ structure: `parking:structure:${structureId}`,
+ timeSeries: `parking:timeseries:${structureId}`
+ });
+
+ private createRedisHashFromStructure = (structure: IParkingStructure): Record => ({
+ id: structure.id,
+ name: structure.name,
+ address: structure.address,
+ capacity: structure.capacity.toString(),
+ spotsAvailable: structure.spotsAvailable.toString(),
+ latitude: structure.coordinates.latitude.toString(),
+ longitude: structure.coordinates.longitude.toString(),
+ updatedTime: structure.updatedTime.toISOString()
+ });
+
+ private createStructureFromRedisData = (data: Record): IParkingStructure => ({
+ id: data.id,
+ name: data.name,
+ address: data.address,
+ capacity: parseInt(data.capacity),
+ spotsAvailable: parseInt(data.spotsAvailable),
+ coordinates: {
+ latitude: parseFloat(data.latitude),
+ longitude: parseFloat(data.longitude)
+ },
+ updatedTime: new Date(data.updatedTime)
+ });
+
+ private shouldLogHistoricalData = (lastAdded: Date | undefined, currentTime: number): boolean => {
+ return !lastAdded || (currentTime - lastAdded.getTime()) >= this.loggingIntervalMs;
+ };
+
+ private addTimeSeriesDataPoint = async (timeSeriesKey: string, timestamp: number, value: number, structureId: string): Promise => {
+ try {
+ await this.redisClient.sendCommand([
+ 'TS.ADD',
+ timeSeriesKey,
+ timestamp.toString(),
+ value.toString(),
+ 'LABELS',
+ 'structureId',
+ structureId
+ ]);
+ } catch (error) {
+ await this.createTimeSeriesAndAddDataPoint(timeSeriesKey, timestamp, value, structureId);
+ }
+ };
+
+ private createTimeSeriesAndAddDataPoint = async (timeSeriesKey: string, timestamp: number, value: number, structureId: string): Promise => {
+ try {
+ await this.redisClient.sendCommand([
+ 'TS.CREATE',
+ timeSeriesKey,
+ 'RETENTION',
+ '2678400000', // one month
+ 'LABELS',
+ 'structureId',
+ structureId
+ ]);
+ await this.redisClient.sendCommand([
+ 'TS.ADD',
+ timeSeriesKey,
+ timestamp.toString(),
+ value.toString()
+ ]);
+ } catch (createError) {
+ await this.redisClient.sendCommand([
+ 'TS.ADD',
+ timeSeriesKey,
+ timestamp.toString(),
+ value.toString()
+ ]);
+ }
+ };
+
+ private calculateAveragesFromRecords = async (
+ id: string,
+ options: ParkingStructureCountOptions
+ ): Promise => {
+ const keys = this.createRedisKeys(id);
+ const { startUnixEpochMs, endUnixEpochMs, intervalMs } = options;
+ const results: HistoricalParkingAverageQueryResult[] = [];
+
+ let currentIntervalStart = startUnixEpochMs;
+
+ while (currentIntervalStart < endUnixEpochMs) {
+ const currentIntervalEnd = Math.min(currentIntervalStart + intervalMs, endUnixEpochMs);
+
+ try {
+ const aggregationResult = await this.redisClient.sendCommand([
+ 'TS.RANGE',
+ keys.timeSeries,
+ currentIntervalStart.toString(),
+ currentIntervalEnd.toString(),
+ 'AGGREGATION',
+ 'AVG',
+ intervalMs.toString()
+ ]) as [string, string][];
+
+ if (aggregationResult && aggregationResult.length > 0) {
+ const [, averageValue] = aggregationResult[0];
+ results.push({
+ fromUnixEpochMs: currentIntervalStart,
+ toUnixEpochMs: currentIntervalEnd,
+ averageSpotsAvailable: parseFloat(averageValue)
+ });
+ }
+ } catch (error) {
+ // If Redis aggregation fails, skip this interval
+ }
+
+ currentIntervalStart = currentIntervalEnd;
+ }
+
+ return results;
+ };
+
+ setLoggingInterval = (intervalMs: number): void => {
+ this.loggingIntervalMs = intervalMs;
+ };
+}
diff --git a/src/types/CircularQueue.ts b/src/types/CircularQueue.ts
new file mode 100644
index 0000000..8d7db7d
--- /dev/null
+++ b/src/types/CircularQueue.ts
@@ -0,0 +1,119 @@
+export class CircularQueue {
+ private startIndex: number;
+ private endIndex: number;
+ private _data: T[];
+ private _size: number;
+ private _capacity: number;
+
+ constructor(
+ size: number,
+ ) {
+ // See the Mozilla documentation on sparse arrays (*not* undefined values)
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Indexed_collections#sparse_arrays
+ this._data = new Array(size);
+ this.startIndex = 0;
+ this.endIndex = 0;
+ this._size = 0;
+ this._capacity = size;
+ }
+
+ size = (): number => this._size;
+
+ get = (index: number): T | undefined => {
+ if (index < 0 || index >= this._size) {
+ return undefined;
+ }
+ const actualIndex = (this.startIndex + index) % this._capacity;
+ return this._data[actualIndex];
+ };
+
+ appendWithSorting = (
+ data: T,
+ sortingCallback: (a: T, b: T) => number
+ ) => {
+ if (this._size === 0) {
+ this._data[this.startIndex] = data;
+ this._size = 1;
+ this.endIndex = this.startIndex;
+ return;
+ }
+
+ const lastItem = this.get(this._size - 1);
+ const isAlreadyInOrder = lastItem && sortingCallback(lastItem, data) <= 0;
+
+ if (this._size < this._capacity) {
+ this.endIndex = (this.endIndex + 1) % this._capacity;
+ this._data[this.endIndex] = data;
+ this._size++;
+ } else {
+ this.startIndex = (this.startIndex + 1) % this._capacity;
+ this.endIndex = (this.endIndex + 1) % this._capacity;
+ this._data[this.endIndex] = data;
+ }
+
+ if (!isAlreadyInOrder) {
+ this.sortData(sortingCallback);
+ }
+ }
+
+ popFront = () => {
+ if (this._size === 0) {
+ return;
+ }
+
+ this._data[this.startIndex] = undefined as any;
+ if (this._size === 1) {
+ this._size = 0;
+ this.startIndex = 0;
+ this.endIndex = 0;
+ } else {
+ this.startIndex = (this.startIndex + 1) % this._capacity;
+ this._size--;
+ }
+ }
+
+ binarySearch = (
+ searchKey: K,
+ keyExtractor: (item: T) => K
+ ): T | undefined => {
+ if (this._size === 0) {
+ return undefined;
+ }
+
+ let left = 0;
+ let right = this._size - 1;
+
+ while (left <= right) {
+ const mid = Math.floor((left + right) / 2);
+ const midItem = this.get(mid)!;
+ const midKey = keyExtractor(midItem);
+
+ if (midKey === searchKey) {
+ return midItem;
+ } else if (midKey < searchKey) {
+ left = mid + 1;
+ } else {
+ right = mid - 1;
+ }
+ }
+
+ return undefined;
+ }
+
+ private sortData = (sortingCallback: (a: T, b: T) => number) => {
+ const items: T[] = [];
+ for (let i = 0; i < this._size; i++) {
+ const item = this.get(i);
+ if (item !== undefined) {
+ items.push(item);
+ }
+ }
+
+ items.sort(sortingCallback);
+
+ for (let i = 0; i < items.length; i++) {
+ const actualIndex = (this.startIndex + i) % this._capacity;
+ this._data[actualIndex] = items[i];
+ }
+ };
+}
diff --git a/test/repositories/InMemoryParkingRepositoryTests.test.ts b/test/repositories/InMemoryParkingRepositoryTests.test.ts
deleted file mode 100644
index d358d14..0000000
--- a/test/repositories/InMemoryParkingRepositoryTests.test.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import { beforeEach, describe, expect, it } from "@jest/globals";
-import { InMemoryParkingRepository } from "../../src/repositories/InMemoryParkingRepository";
-import { IParkingStructure } from "../../src/entities/ParkingRepositoryEntities";
-
-describe("InMemoryParkingRepository", () => {
- let repository: InMemoryParkingRepository;
- const testStructure: IParkingStructure = {
- coordinates: {
- latitude: 33.794795,
- longitude: -117.850807,
- },
- spotsAvailable: 0,
- id: "1",
- name: "Anderson Parking Structure",
- capacity: 100,
- address: "300 E Walnut Ave, Orange, CA 92867",
- updatedTime: new Date(),
- };
-
- beforeEach(() => {
- repository = new InMemoryParkingRepository();
- });
-
- describe("addOrUpdateParkingStructure", () => {
- it("should add a new parking structure", async () => {
- await repository.addOrUpdateParkingStructure(testStructure);
- const result = await repository.getParkingStructureById(testStructure.id);
- expect(result).toEqual(testStructure);
- });
-
- it("should update existing parking structure", async () => {
- await repository.addOrUpdateParkingStructure(testStructure);
- const updatedStructure = { ...testStructure, name: "Updated Garage" };
- await repository.addOrUpdateParkingStructure(updatedStructure);
- const result = await repository.getParkingStructureById(testStructure.id);
- expect(result).toEqual(updatedStructure);
- });
- });
-
- describe("removeParkingStructureIfExists", () => {
- it("should remove existing parking structure and return it", async () => {
- await repository.addOrUpdateParkingStructure(testStructure);
- const removed = await repository.removeParkingStructureIfExists(testStructure.id);
- expect(removed).toEqual(testStructure);
- const result = await repository.getParkingStructureById(testStructure.id);
- expect(result).toBeNull();
- });
-
- it("should return null when removing non-existent structure", async () => {
- const result = await repository.removeParkingStructureIfExists("non-existent");
- expect(result).toBeNull();
- });
- });
-
- describe("clearParkingStructureData", () => {
- it("should remove all parking structures", async () => {
- const structures = [
- testStructure,
- { ...testStructure, id: "test-id-2", name: "Second Garage" }
- ];
-
- for (const structure of structures) {
- await repository.addOrUpdateParkingStructure(structure);
- }
-
- await repository.clearParkingStructureData();
- const result = await repository.getParkingStructures();
- expect(result).toHaveLength(0);
- });
- });
-
- describe("getParkingStructures", () => {
- it("should return empty array when no structures exist", async () => {
- const result = await repository.getParkingStructures();
- expect(result).toEqual([]);
- });
-
- it("should return all added structures", async () => {
- const structures = [
- testStructure,
- { ...testStructure, id: "test-id-2", name: "Second Garage" }
- ];
-
- for (const structure of structures) {
- await repository.addOrUpdateParkingStructure(structure);
- }
-
- const result = await repository.getParkingStructures();
- expect(result).toHaveLength(2);
- expect(result).toEqual(expect.arrayContaining(structures));
- });
- });
-
- describe("getParkingStructureById", () => {
- it("should return null for non-existent structure", async () => {
- const result = await repository.getParkingStructureById("non-existent");
- expect(result).toBeNull();
- });
-
- it("should return structure by id", async () => {
- await repository.addOrUpdateParkingStructure(testStructure);
- const result = await repository.getParkingStructureById(testStructure.id);
- expect(result).toEqual(testStructure);
- });
- });
-});
diff --git a/test/repositories/ParkingRepositorySharedTests.test.ts b/test/repositories/ParkingRepositorySharedTests.test.ts
new file mode 100644
index 0000000..c43cf07
--- /dev/null
+++ b/test/repositories/ParkingRepositorySharedTests.test.ts
@@ -0,0 +1,203 @@
+import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
+import { InMemoryParkingRepository, } from "../../src/repositories/InMemoryParkingRepository";
+import { IParkingStructure } from "../../src/entities/ParkingRepositoryEntities";
+import { ParkingStructureCountOptions } from "../../src/repositories/ParkingGetterRepository";
+import { ParkingGetterSetterRepository } from "../../src/repositories/ParkingGetterSetterRepository";
+import { RedisParkingRepository } from "../../src/repositories/RedisParkingRepository";
+
+interface RepositoryHolder {
+ name: string;
+ factory(): Promise;
+ teardown(): Promise;
+}
+
+class InMemoryParkingRepositoryHolder implements RepositoryHolder {
+ name = 'InMemoryParkingRepository';
+ factory = async () => {
+ return new InMemoryParkingRepository();
+ };
+ teardown = async () => {};
+}
+
+class RedisParkingRepositoryHolder implements RepositoryHolder {
+ repo: RedisParkingRepository | undefined;
+
+ name = 'RedisParkingRepository';
+ factory = async () => {
+ this.repo = new RedisParkingRepository();
+ await this.repo.connect();
+ return this.repo;
+ };
+ teardown = async () => {
+ if (this.repo) {
+ await this.repo.clearAllData();
+ await this.repo.disconnect();
+ }
+ };
+}
+
+const repositoryImplementations = [
+ new InMemoryParkingRepositoryHolder(),
+ new RedisParkingRepositoryHolder(),
+];
+
+describe.each(repositoryImplementations)('$name', (holder) => {
+ let repository: ParkingGetterSetterRepository;
+ const testStructure: IParkingStructure = {
+ coordinates: {
+ latitude: 33.794795,
+ longitude: -117.850807,
+ },
+ spotsAvailable: 0,
+ id: "1",
+ name: "Anderson Parking Structure",
+ capacity: 100,
+ address: "300 E Walnut Ave, Orange, CA 92867",
+ updatedTime: new Date(),
+ };
+
+ beforeEach(async () => {
+ repository = await holder.factory();
+ jest.useRealTimers();
+ });
+
+ afterEach(async () => {
+ await holder.teardown();
+ jest.useRealTimers();
+ });
+
+ describe("addOrUpdateParkingStructure", () => {
+ it("should add a new parking structure", async () => {
+ await repository.addOrUpdateParkingStructure(testStructure);
+ const result = await repository.getParkingStructureById(testStructure.id);
+ expect(result).toEqual(testStructure);
+ });
+
+ it("should update existing parking structure", async () => {
+ await repository.addOrUpdateParkingStructure(testStructure);
+ const updatedStructure = { ...testStructure, name: "Updated Garage" };
+ await repository.addOrUpdateParkingStructure(updatedStructure);
+ const result = await repository.getParkingStructureById(testStructure.id);
+ expect(result).toEqual(updatedStructure);
+ });
+ });
+
+ describe("removeParkingStructureIfExists", () => {
+ it("should remove existing parking structure and return it", async () => {
+ await repository.addOrUpdateParkingStructure(testStructure);
+ const removed = await repository.removeParkingStructureIfExists(testStructure.id);
+ expect(removed).toEqual(testStructure);
+ const result = await repository.getParkingStructureById(testStructure.id);
+ expect(result).toBeNull();
+ });
+
+ it("should return null when removing non-existent structure", async () => {
+ const result = await repository.removeParkingStructureIfExists("non-existent");
+ expect(result).toBeNull();
+ });
+ });
+
+ describe("clearParkingStructureData", () => {
+ it("should remove all parking structures", async () => {
+ const structures = [
+ testStructure,
+ { ...testStructure, id: "test-id-2", name: "Second Garage" }
+ ];
+
+ for (const structure of structures) {
+ await repository.addOrUpdateParkingStructure(structure);
+ }
+
+ await repository.clearParkingStructureData();
+ const result = await repository.getParkingStructures();
+ expect(result).toHaveLength(0);
+ });
+ });
+
+ describe("getParkingStructures", () => {
+ it("should return empty array when no structures exist", async () => {
+ const result = await repository.getParkingStructures();
+ expect(result).toEqual([]);
+ });
+
+ it("should return all added structures", async () => {
+ const structures = [
+ testStructure,
+ { ...testStructure, id: "test-id-2", name: "Second Garage" }
+ ];
+
+ for (const structure of structures) {
+ await repository.addOrUpdateParkingStructure(structure);
+ }
+
+ const result = await repository.getParkingStructures();
+ expect(result).toHaveLength(2);
+ expect(result).toEqual(expect.arrayContaining(structures));
+ });
+ });
+
+ describe("getParkingStructureById", () => {
+ it("should return null for non-existent structure", async () => {
+ const result = await repository.getParkingStructureById("non-existent");
+ expect(result).toBeNull();
+ });
+
+ it("should return structure by id", async () => {
+ await repository.addOrUpdateParkingStructure(testStructure);
+ const result = await repository.getParkingStructureById(testStructure.id);
+ expect(result).toEqual(testStructure);
+ });
+ });
+
+ describe("getHistoricalAveragesOfParkingStructureCounts", () => {
+ it("should return empty array for non-existent structure or no data", async () => {
+ const options: ParkingStructureCountOptions = {
+ startUnixEpochMs: 1000,
+ endUnixEpochMs: 2000,
+ intervalMs: 500
+ };
+
+ expect(await repository.getHistoricalAveragesOfParkingStructureCounts("non-existent", options)).toEqual([]);
+
+ await repository.addOrUpdateParkingStructure(testStructure);
+ expect(await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options)).toEqual([]);
+ });
+
+ it("should calculate averages for intervals with manual historical data", async () => {
+ // Set logging interval to 0 so every update creates historical data
+ repository.setLoggingInterval(0);
+
+ await repository.addOrUpdateParkingStructure(testStructure);
+
+ const updates = [
+ { ...testStructure, spotsAvailable: 80, updatedTime: new Date() },
+ { ...testStructure, spotsAvailable: 70, updatedTime: new Date() },
+ { ...testStructure, spotsAvailable: 60, updatedTime: new Date() },
+ ];
+
+ for (let i = 0; i < updates.length; i++) {
+ // Ensure that different timestamps are created, even after adding the first test structure
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ await repository.addOrUpdateParkingStructure(updates[i]);
+ }
+
+ const now = Date.now();
+ const options: ParkingStructureCountOptions = {
+ startUnixEpochMs: now - 10000, // Look back 10 seconds
+ endUnixEpochMs: now + 10000, // Look forward 10 seconds
+ intervalMs: 20000 // Single large interval
+ };
+
+ const result = await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options);
+
+ // Should have at least some historical data
+ expect(result.length).toEqual(1);
+ if (result.length > 0) {
+ expect(result[0]).toHaveProperty('fromUnixEpochMs');
+ expect(result[0]).toHaveProperty('toUnixEpochMs');
+ expect(result[0]).toHaveProperty('averageSpotsAvailable');
+ expect(result[0].averageSpotsAvailable).toBeCloseTo(52.5);
+ }
+ });
+ });
+});
diff --git a/test/types/CircularQueue.test.ts b/test/types/CircularQueue.test.ts
new file mode 100644
index 0000000..748396d
--- /dev/null
+++ b/test/types/CircularQueue.test.ts
@@ -0,0 +1,201 @@
+import { describe, expect, it } from "@jest/globals";
+import { CircularQueue } from "../../src/types/CircularQueue";
+
+interface TestItem {
+ id: number;
+ value: string;
+}
+
+describe("CircularQueue", () => {
+ const testItems = {
+ first: { id: 1, value: "first" },
+ second: { id: 2, value: "second" },
+ third: { id: 3, value: "third" },
+ fourth: { id: 4, value: "fourth" },
+ test: { id: 1, value: "test" },
+ apple: { id: 1, value: "apple" },
+ banana: { id: 2, value: "banana" },
+ cherry: { id: 3, value: "cherry" },
+ grape: { id: 5, value: "grape" },
+ orange: { id: 7, value: "orange" },
+ a: { id: 1, value: "a" },
+ b: { id: 2, value: "b" },
+ c: { id: 3, value: "c" },
+ d: { id: 4, value: "d" }
+ };
+
+ const sortingCallbacks = {
+ byId: (a: TestItem, b: TestItem) => a.id - b.id,
+ byValue: (a: TestItem, b: TestItem) => a.value.localeCompare(b.value)
+ };
+
+ const keyExtractors = {
+ id: (item: TestItem) => item.id,
+ value: (item: TestItem) => item.value
+ };
+
+ const createQueueWithItems = (size: number, items: TestItem[], sortingCallback: (a: TestItem, b: TestItem) => number) => {
+ const queue = new CircularQueue(size);
+ items.forEach(item => queue.appendWithSorting(item, sortingCallback));
+ return queue;
+ };
+
+ describe("constructor", () => {
+ it("creates queue with specified size", () => {
+ const queue = new CircularQueue(5);
+ expect(queue).toBeDefined();
+ });
+ });
+
+ describe("appendWithSorting", () => {
+ it("adds items to the queue with sorting callback", () => {
+ const queue = createQueueWithItems(3, [testItems.third, testItems.first, testItems.second], sortingCallbacks.byId);
+
+ expect(queue.size()).toBe(3);
+ expect(queue.get(0)).toEqual(testItems.first);
+ expect(queue.get(1)).toEqual(testItems.second);
+ expect(queue.get(2)).toEqual(testItems.third);
+ });
+
+ it("overwrites oldest items when queue is full", () => {
+ const queue = createQueueWithItems(2, [testItems.first, testItems.second, testItems.third], sortingCallbacks.byId);
+
+ expect(queue.size()).toBe(2);
+ });
+
+ it("handles appending to empty queue", () => {
+ const queue = createQueueWithItems(3, [testItems.test], sortingCallbacks.byId);
+
+ expect(queue.size()).toBe(1);
+ expect(queue.get(0)).toEqual(testItems.test);
+ });
+
+ it("optimizes append when items are already in order", () => {
+ const queue = new CircularQueue(5);
+ let sortCallCount = 0;
+
+ const trackingSortCallback = (a: TestItem, b: TestItem) => {
+ sortCallCount++;
+ return a.id - b.id;
+ };
+
+ queue.appendWithSorting(testItems.first, trackingSortCallback);
+ expect(sortCallCount).toBe(0);
+
+ queue.appendWithSorting(testItems.second, trackingSortCallback);
+ expect(sortCallCount).toBe(1);
+
+ queue.appendWithSorting(testItems.third, trackingSortCallback);
+ expect(sortCallCount).toBe(2);
+
+ queue.appendWithSorting({ id: 0, value: "zero" }, trackingSortCallback);
+ expect(sortCallCount).toBeGreaterThan(3);
+
+ expect(queue.get(0)).toEqual({ id: 0, value: "zero" });
+ expect(queue.get(1)).toEqual(testItems.first);
+ });
+ });
+
+ describe("popFront", () => {
+ it("removes the oldest item from queue", () => {
+ const queue = createQueueWithItems(3, [testItems.first, testItems.second], sortingCallbacks.byId);
+
+ expect(queue.size()).toBe(2);
+ queue.popFront();
+ expect(queue.size()).toBe(1);
+ expect(queue.get(0)).toEqual(testItems.second);
+ });
+
+ it("handles popping from empty queue", () => {
+ const queue = new CircularQueue(3);
+
+ expect(() => queue.popFront()).not.toThrow();
+ expect(queue.size()).toBe(0);
+ });
+
+ it("handles popping until empty", () => {
+ const queue = createQueueWithItems(2, [testItems.first, testItems.second], sortingCallbacks.byId);
+
+ queue.popFront();
+ expect(queue.size()).toBe(1);
+ queue.popFront();
+ expect(queue.size()).toBe(0);
+ queue.popFront();
+ expect(queue.size()).toBe(0);
+ });
+ });
+
+ describe("binarySearch", () => {
+ it("finds item using key extractor function", () => {
+ const queue = createQueueWithItems(5, [testItems.apple, testItems.cherry, testItems.grape, testItems.orange], sortingCallbacks.byId);
+
+ const result = queue.binarySearch(5, keyExtractors.id);
+
+ expect(result).toEqual(testItems.grape);
+ });
+
+ it("returns undefined when item not found", () => {
+ const queue = createQueueWithItems(5, [testItems.apple, testItems.cherry, testItems.orange], sortingCallbacks.byId);
+
+ const result = queue.binarySearch(5, keyExtractors.id);
+
+ expect(result).toBeUndefined();
+ });
+
+ it("finds first item", () => {
+ const queue = createQueueWithItems(5, [testItems.apple, testItems.cherry, testItems.orange], sortingCallbacks.byId);
+
+ const result = queue.binarySearch(1, keyExtractors.id);
+
+ expect(result).toEqual(testItems.apple);
+ });
+
+ it("finds last item", () => {
+ const queue = createQueueWithItems(5, [testItems.apple, testItems.cherry, testItems.orange], sortingCallbacks.byId);
+
+ const result = queue.binarySearch(7, keyExtractors.id);
+
+ expect(result).toEqual(testItems.orange);
+ });
+
+ it("returns undefined for empty queue", () => {
+ const queue = new CircularQueue(5);
+ const result = queue.binarySearch(1, keyExtractors.id);
+
+ expect(result).toBeUndefined();
+ });
+
+ it("works with string keys", () => {
+ const queue = createQueueWithItems(5, [testItems.apple, testItems.banana, testItems.cherry], sortingCallbacks.byValue);
+
+ const result = queue.binarySearch("banana", keyExtractors.value);
+
+ expect(result).toEqual(testItems.banana);
+ });
+
+ it("maintains sorted order assumption", () => {
+ const queue = createQueueWithItems(5, [testItems.d, testItems.a, testItems.c, testItems.b], sortingCallbacks.byValue);
+
+ expect(queue.binarySearch("a", keyExtractors.value)).toEqual(testItems.a);
+ expect(queue.binarySearch("b", keyExtractors.value)).toEqual(testItems.b);
+ expect(queue.binarySearch("c", keyExtractors.value)).toEqual(testItems.c);
+ expect(queue.binarySearch("d", keyExtractors.value)).toEqual(testItems.d);
+ expect(queue.binarySearch("z", keyExtractors.value)).toBeUndefined();
+ });
+ });
+
+ describe("integration", () => {
+ it("handles appendWithSorting, popFront, and binarySearch together", () => {
+ const queue = createQueueWithItems(3, [testItems.third, testItems.first, testItems.second], sortingCallbacks.byId);
+
+ expect(queue.binarySearch(2, keyExtractors.id)).toEqual(testItems.second);
+
+ queue.popFront();
+ expect(queue.binarySearch(1, keyExtractors.id)).toBeUndefined();
+ expect(queue.binarySearch(2, keyExtractors.id)).toEqual(testItems.second);
+
+ queue.appendWithSorting(testItems.fourth, sortingCallbacks.byId);
+ expect(queue.binarySearch(4, keyExtractors.id)).toEqual(testItems.fourth);
+ });
+ });
+});