terriajs
Version:
Geospatial data visualization platform.
358 lines (325 loc) • 11.5 kB
text/typescript
import i18next from "i18next";
import L from "leaflet";
import RequestErrorEvent from "terriajs-cesium/Source/Core/RequestErrorEvent";
import Resource from "terriajs-cesium/Source/Core/Resource";
import TileProviderError from "terriajs-cesium/Source/Core/TileProviderError";
import ImageryProvider from "terriajs-cesium/Source/Scene/ImageryProvider";
import WebMapServiceImageryProvider from "terriajs-cesium/Source/Scene/WebMapServiceImageryProvider";
import MappableMixin, { MapItem } from "../../lib/ModelMixins/MappableMixin";
import TileErrorHandlerMixin from "../../lib/ModelMixins/TileErrorHandlerMixin";
import CommonStrata from "../../lib/Models/Definition/CommonStrata";
import CreateModel from "../../lib/Models/Definition/CreateModel";
import Terria from "../../lib/Models/Terria";
import CatalogMemberTraits from "../../lib/Traits/TraitsClasses/CatalogMemberTraits";
import MappableTraits from "../../lib/Traits/TraitsClasses/MappableTraits";
import mixTraits from "../../lib/Traits/mixTraits";
import ImageryProviderTraits from "../../lib/Traits/TraitsClasses/ImageryProviderTraits";
import UrlTraits from "../../lib/Traits/TraitsClasses/UrlTraits";
class TestCatalogItem extends TileErrorHandlerMixin(
MappableMixin(
CreateModel(
mixTraits(
UrlTraits,
ImageryProviderTraits,
MappableTraits,
CatalogMemberTraits
)
)
)
) {
constructor(
name: string,
terria: Terria,
readonly imageryProvider: ImageryProvider
) {
super(name, terria);
this.tileRetryOptions = {
...this.tileRetryOptions,
retries: 3,
minTimeout: 0,
maxTimeout: 0,
randomize: false
};
}
protected forceLoadMapItems(): Promise<void> {
return Promise.resolve();
}
get mapItems(): MapItem[] {
return [
{
imageryProvider: this.imageryProvider,
show: true,
alpha: 1,
clippingRectangle: undefined
}
];
}
}
describe("TileErrorHandlerMixin", function () {
let item: TestCatalogItem;
let imageryProvider: ImageryProvider;
const newError = (
statusCode: number | undefined,
timesRetried = 0
): TileProviderError => {
const httpError = new RequestErrorEvent(statusCode) as any as Error;
return new TileProviderError(
imageryProvider,
"Something broke",
42,
42,
42,
timesRetried,
httpError
);
};
// A convenience function to call tile load error while returning a promise
// that waits for its completion.
function onTileLoadError(item: TestCatalogItem, error: TileProviderError) {
item.onTileLoadError(error);
return new Promise<void>((resolve, reject) => {
const retry: { then?: any; otherwise: any } = error.retry as any;
if (retry && retry.then) {
retry.then(resolve).catch(reject);
} else {
resolve();
}
});
}
beforeEach(function () {
imageryProvider = new WebMapServiceImageryProvider({
url: "/foo",
layers: "0"
});
item = new TestCatalogItem("test", new Terria(), imageryProvider);
item.setTrait(CommonStrata.user, "url", "/foo");
});
it("gives up silently if the failed tile is outside the extent of the item", async function () {
item.setTrait(CommonStrata.user, "rectangle", {
west: 106.9,
east: 120.9,
north: 4.1,
south: 4.3
});
try {
await onTileLoadError(item, newError(400));
} catch {}
expect(item.tileFailures).toBe(0);
});
describe("when statusCode is between 400 and 499", function () {
it("gives up silently if statusCode is 403 and treat403AsError is false", async function () {
try {
item.tileErrorHandlingOptions.setTrait(
CommonStrata.user,
"treat403AsError",
false
);
await onTileLoadError(item, newError(403));
} catch {}
expect(item.tileFailures).toBe(0);
});
it("gives up silently if statusCode is 404 and treat404AsError is false", async function () {
try {
item.tileErrorHandlingOptions.setTrait(
CommonStrata.user,
"treat404AsError",
false
);
await onTileLoadError(item, newError(404));
} catch {}
expect(item.tileFailures).toBe(0);
});
it("fails otherwise", async function () {
item.tileErrorHandlingOptions.setTrait(
CommonStrata.user,
"treat403AsError",
true
);
item.tileErrorHandlingOptions.setTrait(
CommonStrata.user,
"treat404AsError",
true
);
try {
await Promise.all([
onTileLoadError(item, newError(403, 0)),
onTileLoadError(item, newError(404, 1)),
onTileLoadError(item, newError(randomIntBetween(400, 499), 2))
]);
} catch {}
expect(item.tileFailures).toBe(3);
});
});
describe("when statusCode is between 500 and 599", function () {
it("retries fetching the tile using xhr", async function () {
try {
const error = newError(randomIntBetween(500, 599));
spyOn(Resource, "fetchImage").and.rejectWith(error.error);
await onTileLoadError(item, error);
} catch (_e) {}
expect(Resource.fetchImage).toHaveBeenCalled();
});
});
describe("when statusCode is undefined", function () {
let raiseEvent: jasmine.Spy;
beforeEach(function () {
raiseEvent = spyOn(item.terria, "raiseErrorToUser");
item.tileErrorHandlingOptions.setTrait(
CommonStrata.user,
"thresholdBeforeDisablingItem",
0
);
});
it("gives up silently if ignoreUnknownTileErrors is true", async function () {
item.tileErrorHandlingOptions.setTrait(
CommonStrata.user,
"ignoreUnknownTileErrors",
true
);
try {
await onTileLoadError(item, newError(undefined));
} catch {}
expect(item.tileFailures).toBe(0);
expect(raiseEvent.calls.count()).toBe(0);
});
it("fails with bad image error if the error defines a target element", async function () {
try {
const tileProviderError: TileProviderError = newError(undefined);
tileProviderError.error = {
...tileProviderError.error,
target: {}
} as Error;
await onTileLoadError(item, tileProviderError);
} catch {}
expect(item.tileFailures).toBe(1);
expect(raiseEvent.calls.count()).toBe(1);
expect(raiseEvent.calls.argsFor(0)[0]?.message).toContain(
i18next.t("models.imageryLayer.tileErrorMessageII")
);
});
it("ignores error if target src is Leaflet's empty image URL", async function () {
try {
const tileProviderError: TileProviderError = newError(undefined);
tileProviderError.error = {
...tileProviderError.error,
target: { src: L.Util.emptyImageUrl }
} as Error;
await onTileLoadError(item, tileProviderError);
} catch {}
expect(item.tileFailures).toBe(0);
expect(raiseEvent.calls.count()).toBe(0);
});
it("otherwise, it fails with unknown error", async function () {
try {
await onTileLoadError(item, newError(undefined));
} catch {}
expect(item.tileFailures).toBe(1);
expect(raiseEvent.calls.count()).toBe(1);
expect(raiseEvent.calls.argsFor(0)[0]?.message).toContain(
i18next.t("models.imageryLayer.unknownTileErrorMessage")
);
});
});
describe("when performing xhr retries", function () {
it("it fails after retrying a maximum of specified number of times", async function () {
try {
const error = newError(randomIntBetween(500, 599));
spyOn(Resource, "fetchImage").and.rejectWith(error.error);
await onTileLoadError(item, error);
} catch {}
expect(Resource.fetchImage).toHaveBeenCalledTimes(
!Array.isArray(item.tileRetryOptions)
? (item.tileRetryOptions.retries ?? 0)
: 0
);
expect(item.tileFailures).toBe(1);
});
it("tells the map to reload the tile again if an xhr attempt succeeds", async function () {
spyOn(Resource, "fetchImage").and.resolveTo();
await onTileLoadError(item, newError(randomIntBetween(500, 599)));
expect(item.tileFailures).toBe(0);
});
it("fails if the xhr succeeds but the map fails to load the tile for more than 5 times", async function () {
try {
spyOn(Resource, "fetchImage").and.resolveTo();
await onTileLoadError(item, newError(randomIntBetween(500, 599), 0));
await onTileLoadError(item, newError(randomIntBetween(500, 599), 1));
await onTileLoadError(item, newError(randomIntBetween(500, 599), 2));
await onTileLoadError(item, newError(randomIntBetween(500, 599), 3));
await onTileLoadError(item, newError(randomIntBetween(500, 599), 4));
await onTileLoadError(item, newError(randomIntBetween(500, 599), 5));
} catch {}
expect(item.tileFailures).toEqual(1);
});
it("gives up silently if the item is hidden", async function () {
try {
const error = newError(randomIntBetween(500, 599));
spyOn(Resource, "fetchImage").and.rejectWith(error.error);
const result = onTileLoadError(item, error);
item.setTrait(CommonStrata.user, "show", false);
await result;
} catch {}
expect(item.tileFailures).toEqual(0);
});
});
describe("when a tile fails more than the threshold number of times", function () {
beforeEach(function () {
item.tileErrorHandlingOptions.setTrait(
CommonStrata.user,
"thresholdBeforeDisablingItem",
1
);
});
it("reports the last error to the user", async function () {
spyOn(item.terria, "raiseErrorToUser");
try {
await onTileLoadError(item, newError(undefined));
} catch {}
try {
await onTileLoadError(item, newError(undefined, 1));
} catch {}
expect(item.tileFailures).toBe(2);
expect(item.terria.raiseErrorToUser).toHaveBeenCalled();
});
it("disables the catalog item", async function () {
expect(item.show).toBe(true);
try {
await onTileLoadError(item, newError(undefined));
} catch {}
try {
await onTileLoadError(item, newError(undefined, 1));
} catch {}
expect(item.show).toBe(false);
});
});
it("resets tileFailures to 0 when there is an intervening success", async function () {
const error = newError(undefined);
expect(item.tileFailures).toBe(0);
try {
await onTileLoadError(item, error);
} catch {}
expect(item.tileFailures).toBe(1);
try {
error.timesRetried = 1;
await onTileLoadError(item, error);
} catch {}
expect(item.tileFailures).toBe(2);
try {
error.timesRetried = 0;
await onTileLoadError(item, error);
} catch {}
expect(item.tileFailures).toBe(1);
});
it("calls `handleTileError` if the item defines it", async function () {
item.handleTileError = (promise) => promise;
// @ts-expect-error: aaa
spyOn(item, "handleTileError");
try {
await onTileLoadError(item, newError(400));
} catch {}
expect(item.handleTileError).toHaveBeenCalledTimes(1);
});
});
function randomIntBetween(first: number, last: number) {
return Math.floor(first + Math.random() * (last - first));
}