Merge pull request #11 from brendan-ch/bugfix/fix-data-flickering

This commit is contained in:
Brendan Chen
2025-01-22 22:37:53 -08:00
committed by GitHub
7 changed files with 359 additions and 46 deletions

View File

@@ -1,5 +1,5 @@
import { GetterSetterRepository } from "../repositories/GetterSetterRepository";
import { IEta, IRoute, IShuttle, IStop, ISystem } from "../entities/entities";
import { IEntityWithId, IEta, IRoute, IShuttle, IStop, ISystem } from "../entities/entities";
export class ApiResponseError extends Error {
constructor(message: string) {
@@ -8,6 +8,11 @@ export class ApiResponseError extends Error {
}
}
/**
* Class which can load data into a repository from the
* Passio Go API. Supports automatic pruning of all data types
* which inherit from `IEntityWithId`.
*/
export class ApiBasedRepositoryLoader {
supportedSystemIds = ["263"];
baseUrl = "https://passiogo.com/mapGetData.php";
@@ -17,12 +22,25 @@ export class ApiBasedRepositoryLoader {
) {
}
private async constructExistingEntityIdSet<T extends IEntityWithId>(entitySearchCallback: () => Promise<T[]>) {
const existingEntities = await entitySearchCallback();
const ids = new Set<string>();
existingEntities.forEach((entity) => {
ids.add(entity.id);
});
return ids;
}
public async fetchAndUpdateSystemData() {
const params = {
getSystems: "2",
};
const query = new URLSearchParams(params).toString();
const systemIds = await this.constructExistingEntityIdSet(async () => {
return await this.repository.getSystems();
})
try {
const response = await fetch(`${this.baseUrl}?${query}`);
const json = await response.json();
@@ -41,10 +59,16 @@ export class ApiBasedRepositoryLoader {
};
await this.repository.addOrUpdateSystem(constructedSystem);
systemIds.delete(constructedSystem.id);
}));
} else {
throw new Error("Received JSON object does not contain `all` field")
}
// Prune systems
await Promise.all(Array.from(systemIds).map(async (systemId) => {
await this.repository.removeSystemIfExists(systemId);
}));
} catch(e: any) {
throw new ApiResponseError(e.message);
}
@@ -58,6 +82,10 @@ export class ApiBasedRepositoryLoader {
}
public async fetchAndUpdateRouteDataForSystemId(systemId: string) {
const routeIdsToPrune = await this.constructExistingEntityIdSet(async () => {
return await this.repository.getRoutesBySystemId(systemId);
});
const params = {
getRoutes: "2",
};
@@ -89,8 +117,14 @@ export class ApiBasedRepositoryLoader {
};
await this.repository.addOrUpdateRoute(constructedRoute);
routeIdsToPrune.delete(constructedRoute.id);
}))
}
await Promise.all(Array.from(routeIdsToPrune).map(async (routeId) => {
await this.repository.removeRouteIfExists(routeId);
}));
} catch(e: any) {
throw new ApiResponseError(e.message);
}
@@ -106,6 +140,10 @@ export class ApiBasedRepositoryLoader {
public async fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId(systemId: string) {
// Fetch from the API
// Pass JSON output into two different methods to update repository
const stopIdsToPrune = await this.constructExistingEntityIdSet(async () => {
return await this.repository.getStopsBySystemId(systemId);
});
const params = {
getStops: "2",
};
@@ -126,9 +164,13 @@ export class ApiBasedRepositoryLoader {
});
const json = await response.json();
await this.updateStopDataForSystemAndApiResponse(systemId, json);
await this.updateStopDataForSystemAndApiResponse(systemId, json, stopIdsToPrune);
await this.updateOrderedStopDataForExistingStops(json);
await this.updatePolylineDataForExistingRoutesAndApiResponse(json);
await Promise.all(Array.from(stopIdsToPrune).map(async (stopId) => {
await this.repository.removeStopIfExists(stopId);
}));
} catch(e: any) {
throw new ApiResponseError(e.message);
}
@@ -143,6 +185,10 @@ export class ApiBasedRepositoryLoader {
}
public async fetchAndUpdateShuttleDataForSystemId(systemId: string) {
const shuttleIdsToPrune = await this.constructExistingEntityIdSet(async () => {
return await this.repository.getShuttlesBySystemId(systemId);
});
const params = {
getBuses: "2"
};
@@ -182,8 +228,14 @@ export class ApiBasedRepositoryLoader {
}
await this.repository.addOrUpdateShuttle(constructedShuttle);
shuttleIdsToPrune.delete(constructedShuttle.id);
}));
}
await Promise.all(Array.from(shuttleIdsToPrune).map(async (shuttleId) => {
await this.repository.removeShuttleIfExists(shuttleId);
}));
} catch(e: any) {
throw new ApiResponseError(e.message);
}
@@ -240,7 +292,11 @@ export class ApiBasedRepositoryLoader {
}
}
protected async updateStopDataForSystemAndApiResponse(systemId: string, json: any) {
protected async updateStopDataForSystemAndApiResponse(
systemId: string,
json: any,
setOfIdsToPrune: Set<string> = new Set(),
) {
if (json.stops) {
const jsonStops = Object.values(json.stops);
@@ -256,6 +312,8 @@ export class ApiBasedRepositoryLoader {
};
await this.repository.addOrUpdateStop(constructedStop);
setOfIdsToPrune.delete(constructedStop.id);
}));
}
}

View File

@@ -47,15 +47,10 @@ export class TimedApiBasedRepositoryLoader extends ApiBasedRepositoryLoader {
if (!this.shouldBeRunning) return;
try {
await this.repository.clearSystemData();
await this.fetchAndUpdateSystemData();
await this.repository.clearRouteData();
await this.fetchAndUpdateRouteDataForExistingSystemsInRepository();
await this.repository.clearStopData();
await this.fetchAndUpdateStopAndPolylineDataForRoutesInExistingSystemsInRepository();
await this.repository.clearShuttleData();
await this.fetchAndUpdateShuttleDataForExistingSystemsInRepository();
await this.repository.clearEtaData();
await this.fetchAndUpdateEtaDataForExistingStopsForSystemsInRepository();
} catch (e) {
console.error(e);

View File

@@ -19,6 +19,13 @@ export interface GetterSetterRepository extends GetterRepository {
addOrUpdateOrderedStop(orderedStop: IOrderedStop): Promise<void>;
addOrUpdateEta(eta: IEta): Promise<void>;
removeSystemIfExists(systemId: string): Promise<ISystem | null>;
removeRouteIfExists(routeId: string): Promise<IRoute | null>;
removeShuttleIfExists(shuttleId: string): Promise<IShuttle | null>;
removeStopIfExists(stopId: string): Promise<IStop | null>;
removeOrderedStopIfExists(stopId: string, routeId: string): Promise<IOrderedStop | null>;
removeEtaIfExists(shuttleId: string, stopId: string): Promise<IEta | null>;
clearSystemData(): Promise<void>;
clearRouteData(): Promise<void>;
clearShuttleData(): Promise<void>;

View File

@@ -1,13 +1,5 @@
import { GetterSetterRepository } from "./GetterSetterRepository";
import {
IEntityWithId,
IEta,
IOrderedStop,
IRoute,
IShuttle,
IStop,
ISystem
} from "../entities/entities";
import { IEntityWithId, IEta, IOrderedStop, IRoute, IShuttle, IStop, ISystem } from "../entities/entities";
/**
* An unoptimized in memory repository.
@@ -148,6 +140,51 @@ export class UnoptimizedInMemoryRepository implements GetterSetterRepository {
}
}
private async removeEntityByMatcherIfExists<T>(callback: (value: T) => boolean, arrayToSearchIn: T[]) {
const index = arrayToSearchIn.findIndex(callback);
if (index > -1) {
const entityToReturn = arrayToSearchIn[index];
arrayToSearchIn.splice(index, 1);
return entityToReturn;
}
return null;
}
private async removeEntityByIdIfExists<T extends IEntityWithId>(entityId: string, arrayToSearchIn: T[]) {
return await this.removeEntityByMatcherIfExists((value) => value.id === entityId, arrayToSearchIn);
}
public async removeSystemIfExists(systemId: string): Promise<ISystem | null> {
return await this.removeEntityByIdIfExists(systemId, this.systems);
}
public async removeRouteIfExists(routeId: string): Promise<IRoute | null> {
return await this.removeEntityByIdIfExists(routeId, this.routes);
}
public async removeShuttleIfExists(shuttleId: string): Promise<IShuttle | null> {
return await this.removeEntityByIdIfExists(shuttleId, this.shuttles);
}
public async removeStopIfExists(stopId: string): Promise<IStop | null> {
return await this.removeEntityByIdIfExists(stopId, this.stops);
}
public async removeOrderedStopIfExists(stopId: string, routeId: string): Promise<IOrderedStop | null> {
return await this.removeEntityByMatcherIfExists((orderedStop) => {
return orderedStop.stopId === stopId
&& orderedStop.routeId === routeId
}, this.orderedStops);
}
public async removeEtaIfExists(shuttleId: string, stopId: string): Promise<IEta | null> {
return await this.removeEntityByMatcherIfExists((eta) => {
return eta.stopId === stopId
&& eta.shuttleId === shuttleId
}, this.etas);
}
public async clearSystemData() {
this.systems = [];
}
@@ -171,4 +208,5 @@ export class UnoptimizedInMemoryRepository implements GetterSetterRepository {
public async clearStopData(): Promise<void> {
this.stops = [];
}
}

View File

@@ -7,7 +7,14 @@ import { fetchRouteDataSuccessfulResponse } from "../jsonSnapshots/fetchRouteDat
import {
fetchStopAndPolylineDataSuccessfulResponse
} from "../jsonSnapshots/fetchStopAndPolylineData/fetchStopAndPolylineDataSuccessfulResponse";
import { generateMockStops, generateMockSystems } from "../generators";
import {
generateMockEtas,
generateMockOrderedStops,
generateMockRoutes,
generateMockShuttles,
generateMockStops,
generateMockSystems
} from "../generators";
import { IStop } from "../../src/entities/entities";
import {
fetchShuttleDataSuccessfulResponse
@@ -33,11 +40,19 @@ describe("ApiBasedRepositoryLoader", () => {
describe("fetchAndUpdateSystemData", () => {
it("updates system data in repository if response received", async () => {
// Arrange
const systemsToPrune = generateMockSystems();
await Promise.all(systemsToPrune.map(async (system) => {
await loader.repository.addOrUpdateSystem(system);
}));
const numberOfSystemsInResponse = fetchSystemDataSuccessfulResponse.all.length;
updateGlobalFetchMockJson(fetchSystemDataSuccessfulResponse);
// Act
await loader.fetchAndUpdateSystemData();
// Assert
const systems = await loader.repository.getSystems();
if (loader.supportedSystemIds.length < numberOfSystemsInResponse) {
expect(systems).toHaveLength(loader.supportedSystemIds.length);
@@ -81,12 +96,23 @@ describe("ApiBasedRepositoryLoader", () => {
});
describe("fetchAndUpdateRouteDataForSystemId", () => {
const systemId = "263";
it("updates route data in repository if response received", async () => {
// Arrange
// Test pruning
const routesToPrune = generateMockRoutes();
await Promise.all(routesToPrune.map(async (route) => {
route.systemId = systemId;
await loader.repository.addOrUpdateRoute(route);
}));
updateGlobalFetchMockJson(fetchRouteDataSuccessfulResponse);
await loader.fetchAndUpdateRouteDataForSystemId("263");
// Act
await loader.fetchAndUpdateRouteDataForSystemId(systemId);
const routes = await loader.repository.getRoutesBySystemId("263");
// Assert
const routes = await loader.repository.getRoutesBySystemId(systemId);
expect(routes.length).toEqual(fetchRouteDataSuccessfulResponse.all.length)
});
@@ -98,7 +124,7 @@ describe("ApiBasedRepositoryLoader", () => {
updateGlobalFetchMockJsonToThrowSyntaxError();
await assertAsyncCallbackThrowsApiResponseError(async () => {
await loader.fetchAndUpdateRouteDataForSystemId("263");
await loader.fetchAndUpdateRouteDataForSystemId(systemId);
});
});
});
@@ -120,14 +146,23 @@ describe("ApiBasedRepositoryLoader", () => {
})
describe("fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId", () => {
const systemId = "263";
it("updates stop and polyline data if response received", async () => {
// Arrange
// Test pruning of stops only
const stopsToPrune = generateMockStops();
await Promise.all(stopsToPrune.map(async (stop) => {
stop.systemId = systemId;
await loader.repository.addOrUpdateStop(stop);
}));
updateGlobalFetchMockJson(fetchStopAndPolylineDataSuccessfulResponse);
const stopsArray = Object.values(fetchStopAndPolylineDataSuccessfulResponse.stops);
await loader.fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId("263");
await loader.fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId(systemId);
const stops = await loader.repository.getStopsBySystemId("263");
const stops = await loader.repository.getStopsBySystemId(systemId);
expect(stops.length).toEqual(stopsArray.length);
await Promise.all(stops.map(async (stop) => {
@@ -135,7 +170,7 @@ describe("ApiBasedRepositoryLoader", () => {
expect(orderedStops.length).toBeGreaterThan(0);
}));
const routes = await loader.repository.getRoutesBySystemId("263");
const routes = await loader.repository.getRoutesBySystemId(systemId);
routes.forEach((route) => {
expect(route.polylineCoordinates.length).toBeGreaterThan(0);
});
@@ -145,7 +180,7 @@ describe("ApiBasedRepositoryLoader", () => {
updateGlobalFetchMockJsonToThrowSyntaxError();
await assertAsyncCallbackThrowsApiResponseError(async () => {
await loader.fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId("263");
await loader.fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId(systemId);
});
})
});
@@ -166,13 +201,20 @@ describe("ApiBasedRepositoryLoader", () => {
});
describe("fetchAndUpdateShuttleDataForSystemId", () => {
const systemId = "263";
it("updates shuttle data in repository if response received", async () => {
const shuttlesToPrune = generateMockShuttles();
await Promise.all(shuttlesToPrune.map(async (shuttle) => {
shuttle.systemId = systemId;
await loader.repository.addOrUpdateShuttle(shuttle);
}))
updateGlobalFetchMockJson(fetchShuttleDataSuccessfulResponse);
const busesInResponse = Object.values(fetchShuttleDataSuccessfulResponse.buses);
await loader.fetchAndUpdateShuttleDataForSystemId("263");
await loader.fetchAndUpdateShuttleDataForSystemId(systemId);
const shuttles = await loader.repository.getShuttlesBySystemId("263");
const shuttles = await loader.repository.getShuttlesBySystemId(systemId);
expect(shuttles.length).toEqual(busesInResponse.length);
});
@@ -181,7 +223,7 @@ describe("ApiBasedRepositoryLoader", () => {
updateGlobalFetchMockJsonToThrowSyntaxError();
await assertAsyncCallbackThrowsApiResponseError(async () => {
await loader.fetchAndUpdateShuttleDataForSystemId("263");
await loader.fetchAndUpdateShuttleDataForSystemId(systemId);
});
});
});
@@ -221,9 +263,9 @@ describe("ApiBasedRepositoryLoader", () => {
});
describe("fetchAndUpdateEtaDataForStopId", () => {
const stopId = "177666";
it("updates ETA data for stop id if response received", async () => {
updateGlobalFetchMockJson(fetchEtaDataSuccessfulResponse);
const stopId = "177666";
// @ts-ignore
const etasFromResponse = fetchEtaDataSuccessfulResponse.ETAs[stopId]

View File

@@ -1,10 +1,9 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals";
import { TimedApiBasedRepositoryLoader } from "../../src/loaders/TimedApiBasedRepositoryLoader";
import { resetGlobalFetchMockJson } from "../mockHelpers/fetchMockHelpers";
import { GetterSetterRepository } from "../../src/repositories/GetterSetterRepository";
import { UnoptimizedInMemoryRepository } from "../../src/repositories/UnoptimizedInMemoryRepository";
describe("TimedApiBasedRepositoryLoader", () => {
let repositoryMock: GetterSetterRepository;
let loader: TimedApiBasedRepositoryLoader;
let spies: any;
@@ -16,15 +15,7 @@ describe("TimedApiBasedRepositoryLoader", () => {
beforeEach(() => {
resetGlobalFetchMockJson();
repositoryMock = {
clearSystemData: jest.fn(),
clearRouteData: jest.fn(),
clearStopData: jest.fn(),
clearShuttleData: jest.fn(),
clearEtaData: jest.fn(),
} as unknown as GetterSetterRepository;
loader = new TimedApiBasedRepositoryLoader(repositoryMock);
loader = new TimedApiBasedRepositoryLoader(new UnoptimizedInMemoryRepository());
spies = {
fetchAndUpdateSystemData: jest.spyOn(loader, 'fetchAndUpdateSystemData'),
@@ -49,9 +40,6 @@ describe("TimedApiBasedRepositoryLoader", () => {
await loader.start();
expect(loader["shouldBeRunning"]).toBe(true);
Object.values(repositoryMock).forEach((mockFn) => {
expect(mockFn).toHaveBeenCalled();
});
Object.values(spies).forEach((spy: any) => {
expect(spy).toHaveBeenCalled();
});
@@ -64,9 +52,6 @@ describe("TimedApiBasedRepositoryLoader", () => {
await loader.start();
await loader.start();
Object.values(repositoryMock).forEach((mockFn) => {
expect(mockFn).toHaveBeenCalledTimes(1);
});
Object.values(spies).forEach((spy: any) => {
expect(spy).toHaveBeenCalledTimes(1);
});

View File

@@ -429,6 +429,194 @@ describe("UnoptimizedInMemoryRepository", () => {
});
});
describe("removeSystemIfExists", () => {
test("removes system given ID", async () => {
const mockSystems = generateMockSystems();
await Promise.all(mockSystems.map(async (system) => {
await repository.addOrUpdateSystem(system);
}));
const systemToRemove = mockSystems[0];
await repository.removeSystemIfExists(systemToRemove.id);
const remainingSystems = await repository.getSystems();
expect(remainingSystems).toHaveLength(mockSystems.length - 1);
});
test("does nothing if system doesn't exist", async () => {
const mockSystems = generateMockSystems();
await Promise.all(mockSystems.map(async (system) => {
await repository.addOrUpdateSystem(system);
}));
await repository.removeSystemIfExists("nonexistent-id");
const remainingSystems = await repository.getSystems();
expect(remainingSystems).toHaveLength(mockSystems.length);
});
});
describe("removeRouteIfExists", () => {
test("removes route given ID", async () => {
const systemId = "1";
const mockRoutes = generateMockRoutes();
await Promise.all(mockRoutes.map(async (route) => {
route.systemId = systemId;
await repository.addOrUpdateRoute(route);
}));
const routeToRemove = mockRoutes[0];
await repository.removeRouteIfExists(routeToRemove.id);
const remainingRoutes = await repository.getRoutesBySystemId(systemId);
expect(remainingRoutes).toHaveLength(mockRoutes.length - 1);
});
test("does nothing if route doesn't exist", async () => {
const systemId = "1";
const mockRoutes = generateMockRoutes();
await Promise.all(mockRoutes.map(async (route) => {
route.systemId = systemId;
await repository.addOrUpdateRoute(route);
}));
await repository.removeRouteIfExists("nonexistent-id");
const remainingRoutes = await repository.getRoutesBySystemId(systemId);
expect(remainingRoutes).toHaveLength(mockRoutes.length);
});
});
describe("removeShuttleIfExists", () => {
test("removes shuttle given ID", async () => {
const systemId = "1";
const mockShuttles = generateMockShuttles();
await Promise.all(mockShuttles.map(async (shuttle) => {
shuttle.systemId = systemId;
await repository.addOrUpdateShuttle(shuttle);
}));
const shuttleToRemove = mockShuttles[0];
await repository.removeShuttleIfExists(shuttleToRemove.id);
const remainingShuttles = await repository.getShuttlesBySystemId(systemId);
expect(remainingShuttles).toHaveLength(mockShuttles.length - 1);
});
test("does nothing if shuttle doesn't exist", async () => {
const systemId = "1";
const mockShuttles = generateMockShuttles();
await Promise.all(mockShuttles.map(async (shuttle) => {
shuttle.systemId = systemId;
await repository.addOrUpdateShuttle(shuttle);
}));
await repository.removeShuttleIfExists("nonexistent-id");
const remainingShuttles = await repository.getShuttlesBySystemId(systemId);
expect(remainingShuttles).toHaveLength(mockShuttles.length);
});
});
describe("removeStopIfExists", () => {
test("removes stop given ID", async () => {
const systemId = "1";
const mockStops = generateMockStops();
await Promise.all(mockStops.map(async (stop) => {
stop.systemId = systemId;
await repository.addOrUpdateStop(stop);
}));
const stopToRemove = mockStops[0];
await repository.removeStopIfExists(stopToRemove.id);
const remainingStops = await repository.getStopsBySystemId(systemId);
expect(remainingStops).toHaveLength(mockStops.length - 1);
});
test("does nothing if stop doesn't exist", async () => {
const systemId = "1";
const mockStops = generateMockStops();
await Promise.all(mockStops.map(async (stop) => {
stop.systemId = systemId;
await repository.addOrUpdateStop(stop);
}));
await repository.removeStopIfExists("nonexistent-id");
const remainingStops = await repository.getStopsBySystemId(systemId);
expect(remainingStops).toHaveLength(mockStops.length);
});
});
describe("removeOrderedStopIfExists", () => {
test("removes ordered stop given stop ID and route ID", async () => {
let mockOrderedStops = generateMockOrderedStops();
const routeId = mockOrderedStops[0].routeId;
mockOrderedStops = mockOrderedStops.filter((orderedStop) => orderedStop.routeId === routeId);
await Promise.all(mockOrderedStops.map(async (stop) => {
stop.routeId = routeId;
await repository.addOrUpdateOrderedStop(stop);
}));
const orderedStopToRemove = mockOrderedStops[0];
await repository.removeOrderedStopIfExists(orderedStopToRemove.stopId, orderedStopToRemove.routeId);
const remainingOrderedStops = await repository.getOrderedStopsByRouteId(routeId);
expect(remainingOrderedStops).toHaveLength(mockOrderedStops.length - 1);
});
test("does nothing if ordered stop doesn't exist", async () => {
let mockOrderedStops = generateMockOrderedStops();
const routeId = mockOrderedStops[0].routeId;
mockOrderedStops = mockOrderedStops.filter((orderedStop) => orderedStop.routeId === routeId);
await Promise.all(mockOrderedStops.map(async (stop) => {
stop.routeId = routeId;
await repository.addOrUpdateOrderedStop(stop);
}));
await repository.removeOrderedStopIfExists("nonexistent-stop-id", "nonexistent-route-id");
const remainingOrderedStops = await repository.getOrderedStopsByRouteId(routeId);
expect(remainingOrderedStops).toHaveLength(mockOrderedStops.length);
});
});
describe("removeEtaIfExists", () => {
test("removes eta given shuttle ID and stop ID", async () => {
let mockEtas = generateMockEtas();
const stopId = mockEtas[0].stopId;
mockEtas = mockEtas.filter((eta) => eta.stopId === stopId);
await Promise.all(mockEtas.map(async (eta) => {
eta.stopId = stopId;
await repository.addOrUpdateEta(eta);
}));
const etaToRemove = mockEtas[0];
await repository.removeEtaIfExists(etaToRemove.shuttleId, etaToRemove.stopId);
const remainingEtas = await repository.getEtasForStopId(stopId);
expect(remainingEtas).toHaveLength(mockEtas.length - 1);
});
test("does nothing if eta doesn't exist", async () => {
let mockEtas = generateMockEtas();
const stopId = mockEtas[0].stopId;
mockEtas = mockEtas.filter((eta) => eta.stopId === stopId);
await Promise.all(mockEtas.map(async (eta) => {
eta.stopId = stopId;
await repository.addOrUpdateEta(eta);
}));
await repository.removeEtaIfExists("nonexistent-shuttle-id", "nonexistent-stop-id");
const remainingEtas = await repository.getEtasForStopId(stopId);
expect(remainingEtas).toHaveLength(mockEtas.length);
});
});
describe("clearSystemData", () => {
test("clears all systems from the repository", async () => {
const mockSystems = generateMockSystems();