Design of separated events: drop update data support for tile-loaded, use only invalidated event.

This commit is contained in:
Aiosa 2024-11-01 21:43:12 +01:00
parent cd60aff5dc
commit f127014f0f
8 changed files with 169 additions and 130 deletions

View File

@ -199,8 +199,9 @@ $.EventSource.prototype = {
* calling the handler for each and awaiting async ones.
* @function
* @param {String} eventName - Name of event to get handlers for.
* @param {any} bindTarget - Bound target to return with the promise on finish
*/
getAwaitingHandler: function ( eventName) {
getAwaitingHandler: function ( eventName, bindTarget ) {
let events = this.events[ eventName ];
if ( !events || !events.length ) {
return null;
@ -217,7 +218,7 @@ $.EventSource.prototype = {
const length = events.length;
function loop(index) {
if ( index >= length || !events[ index ] ) {
resolve("Resolved!");
resolve(bindTarget);
return null;
}
args.eventSource = source;
@ -259,17 +260,18 @@ $.EventSource.prototype = {
* This events awaits every asynchronous or promise-returning function.
* @param {String} eventName - Name of event to register.
* @param {Object} eventArgs - Event-specific data.
* @param {?} [bindTarget = null] - Promise-resolved value on the event finish
* @return {OpenSeadragon.Promise|undefined} - Promise resolved upon the event completion.
*/
raiseEventAwaiting: function ( eventName, eventArgs ) {
raiseEventAwaiting: function ( eventName, eventArgs, bindTarget = null ) {
//uncomment if you want to get a log of all events
//$.console.log( "Awaiting event fired:", eventName );
const awaitingHandler = this.getAwaitingHandler(eventName);
const awaitingHandler = this.getAwaitingHandler(eventName, bindTarget);
if (awaitingHandler) {
return awaitingHandler(this, eventArgs || {});
}
return $.Promise.resolve("No handler for this event registered.");
return $.Promise.resolve(bindTarget);
},
/**

View File

@ -567,25 +567,34 @@ $.Tile.prototype = {
* @private
* @return {OpenSeadragon.Promise<?>}
*/
updateRenderTargetWithDataTransform: function (drawerId, supportedFormats, usePrivateCache) {
updateRenderTargetWithDataTransform: function (drawerId, supportedFormats, usePrivateCache, processTimestamp) {
// Now, if working cache exists, we set main cache to the working cache --> prepare
const cache = this.getCache(this._wcKey);
let cache = this.getCache(this._wcKey);
if (cache) {
return cache.prepareForRendering(drawerId, supportedFormats, usePrivateCache, this.processing);
return cache.prepareForRendering(drawerId, supportedFormats, usePrivateCache).then(c => {
if (c && processTimestamp && this.processing === processTimestamp) {
this.updateRenderTarget();
}
});
}
// If we requested restore, perform now
if (this.__restore) {
const cache = this.getCache(this.originalCacheKey);
cache = this.getCache(this.originalCacheKey);
this.tiledImage._tileCache.restoreTilesThatShareOriginalCache(
this, cache, this.__restoreRequestedFree
);
this.__restore = false;
return cache.prepareForRendering(drawerId, supportedFormats, usePrivateCache, this.processing);
return cache.prepareForRendering(drawerId, supportedFormats, usePrivateCache).then((c) => {
if (c && processTimestamp && this.processing === processTimestamp) {
this.updateRenderTarget();
}
});
}
return $.Promise.resolve();
cache = this.getCache();
return cache.prepareForRendering(drawerId, supportedFormats, usePrivateCache);
},
/**

View File

@ -223,32 +223,18 @@
* @param drawerId
* @param supportedTypes
* @param keepInternalCopy
* @param _shareTileUpdateStamp private param, updates render target (swap cache memory) for tiles that come
* from the same tstamp batch
* @return {OpenSeadragon.Promise<OpenSeadragon.SimpleCacheRecord|OpenSeadragon.CacheRecord>}
* @return {OpenSeadragon.Promise<OpenSeadragon.SimpleCacheRecord|OpenSeadragon.CacheRecord> | null}
* reference to the cache processed for drawer rendering requirements, or null on error
*/
prepareForRendering(drawerId, supportedTypes, keepInternalCopy = true, _shareTileUpdateStamp = null) {
const fin = () => {
// Locked update of render target to the tile that initiated processing
if (_shareTileUpdateStamp) {
for (let tile of this._tiles) {
if (tile.processing === _shareTileUpdateStamp) {
tile.updateRenderTarget();
}
}
}
return this;
};
prepareForRendering(drawerId, supportedTypes, keepInternalCopy = true) {
// if not internal copy and we have no data, or we are ready to render, exit
if (!this.loaded || supportedTypes.includes(this.type)) {
fin();
return $.Promise.resolve(this);
}
if (!keepInternalCopy) {
return this.transformTo(supportedTypes).then(fin);
return this.transformTo(supportedTypes);
}
// we can get here only if we want to render incompatible type
@ -260,11 +246,10 @@
internalCache = internalCache[drawerId];
if (internalCache) {
// already done
fin();
return $.Promise.resolve(this);
} else {
internalCache = this[DRAWER_INTERNAL_CACHE][drawerId] = new $.SimpleCacheRecord();
}
internalCache = this[DRAWER_INTERNAL_CACHE][drawerId] = new $.SimpleCacheRecord();
const conversionPath = $.convertor.getConversionPath(this.type, supportedTypes);
if (!conversionPath) {
$.console.error(`[getDataForRendering] Conversion ${this.type} ---> ${supportedTypes} cannot be done!`);
@ -273,8 +258,8 @@
internalCache.withTileReference(this._tRef);
const selectedFormat = conversionPath[conversionPath.length - 1].target.value;
return $.convertor.convert(this._tRef, this.data, this.type, selectedFormat).then(data => {
internalCache.setDataAs(data, selectedFormat); // synchronous, SimpleCacheRecord
return fin();
internalCache.setDataAs(data, selectedFormat); // synchronous, SimpleCacheRecord call
return internalCache;
});
}
@ -842,10 +827,6 @@
}
cacheRecord.addTile(theTile, options.data, options.dataType);
if (cacheKey === theTile.cacheKey) {
theTile.tiledImage._needsDraw = true;
}
this._freeOldRecordRoutine(theTile, options.cutoff || 0);
return cacheRecord;
}
@ -949,10 +930,7 @@
// We need to avoid async execution here: replace consumer instead of overwriting the data.
const iterateTiles = [...consumer._tiles]; // unloadCacheForTile() will modify the array, use a copy
for (let tile of iterateTiles) {
if (tile.loaded || tile.loading) {
this.unloadCacheForTile(tile, options.consumerKey, true);
}
this.unloadCacheForTile(tile, options.consumerKey, true, false);
}
}
if (this._cachesLoaded[options.consumerKey]) {
@ -968,7 +946,7 @@
// Only one cache got working item, other caches were idle: update cache: add the new cache
// we can add since we removed above with unloadCacheForTile()
for (let tile of tiles) {
if (tile !== options.tile && (tile.loaded || tile.loading)) {
if (tile !== options.tile) {
tile.addCache(options.consumerKey, resultCache.data, resultCache.type, true, false);
}
}
@ -985,7 +963,7 @@
*/
restoreTilesThatShareOriginalCache(tile, originalCache, freeIfUnused) {
for (let t of originalCache._tiles) {
this.unloadCacheForTile(t, t.cacheKey, freeIfUnused);
this.unloadCacheForTile(t, t.cacheKey, freeIfUnused, false);
delete t._caches[t.cacheKey];
t.cacheKey = t.originalCacheKey;
}
@ -1016,7 +994,7 @@
if ( prevTile.level <= cutoff ||
prevTile.beingDrawn ||
prevTile.loading ||
prevTile.processing ) {
prevTile.processing ) { //todo exempt from deletion, or block this routine on data updates
continue;
}
if ( !worstTile ) {
@ -1171,7 +1149,7 @@
for (let key in tile._caches) {
//we are 'ok' to remove tile caches here since we later call destroy on tile, otherwise
//tile has count of its cache size --> would be inconsistent
this.unloadCacheForTile(tile, key, destroy);
this.unloadCacheForTile(tile, key, destroy, false);
}
//delete also the tile record
if (deleteAtIndex !== undefined) {

View File

@ -2116,45 +2116,68 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
increment = 0,
eventFinished = false;
const _this = this,
finishPromise = new $.Promise(r => {
resolver = r;
});
now = $.now();
function completionCallback() {
increment--;
if (increment > 0) {
return;
}
eventFinished = true;
//do not override true if set (false is default)
tile.hasTransparency = tile.hasTransparency || _this.source.hasTransparency(
undefined, tile.getUrl(), tile.ajaxHeaders, tile.postData
);
tile.updateRenderTarget(true);
//make sure cache data is ready for drawing, if not, request the desired format
const cache = tile.getCache(tile.cacheKey),
requiredTypes = _this._drawer.getSupportedDataFormats();
if (!cache) {
$.console.warn("Tile %s not cached or not loaded at the end of tile-loaded event: tile will not be drawn - it has no data!", tile);
resolver(tile);
} else if (!requiredTypes.includes(cache.type)) {
//initiate conversion as soon as possible if incompatible with the drawer
cache.prepareForRendering(_this._drawer.getId(), requiredTypes, _this._drawer.options.usePrivateCache).then(cacheRef => {
if (!cacheRef) {
return cache.transformTo(requiredTypes);
// tile.updateRenderTarget(true);
// //make sure cache data is ready for drawing, if not, request the desired format
// const cache = tile.getCache(),
// requiredTypes = _this._drawer.getSupportedDataFormats();
// if (!cache) {
// $.console.warn("Tile %s not cached or not loaded at the end of tile-loaded event: tile will not be drawn - it has no data!", tile);
// resolver();
// } else if (!requiredTypes.includes(cache.type)) {
// //initiate conversion as soon as possible if incompatible with the drawer
// //either the cache is a new item in the system (do process), or the cache inherits data from elsewhere (no-op),
// // or the cache was processed in this call
// tile.transforming = now; // block any updates on the tile
// cache.prepareForRendering(_this._drawer.getId(), requiredTypes, _this._drawer.options.usePrivateCache).then(cacheRef => {
// if (!cacheRef) {
// return cache.transformTo(requiredTypes);
// }
// if (tile.processing === now) {
// tile.updateRenderTarget();
// }
// return cacheRef;
// }).then(resolver);
// } else {
// resolver();
// }
// TODO consider first running this event before we call tile-loaded...
if (!tileCacheCreated) {
// Tile-loaded not called on each tile, but only on tiles with new data! Verify we share the main cache
const origCache = tile.getCache(tile.originalCacheKey);
for (let t of origCache._tiles) {
// if there exists a tile that has different main cache, inherit it as a main cache
if (t.cacheKey !== tile.cacheKey) {
// add reference also to the main cache, no matter what the other tile state has
// completion of the invaldate event should take care of all such tiles
const targetMainCache = t.getCache();
tile.addCache(t.cacheKey, () => {
$.console.error("Attempt to share main cache with existing tile should not trigger data getter!");
return targetMainCache.data;
}, targetMainCache.type, true, false);
break;
}
return cacheRef;
}).then(_ => {
tile.loading = false;
tile.loaded = true;
tile.lastProcess = 1;
resolver(tile);
});
} else {
tile.loading = false;
tile.loaded = true;
tile.lastProcess = 1;
resolver(tile);
}
resolver();
return;
}
// In case we did not succeed in tile restoration, request invalidation todo what about catch
const updatePromise = _this.viewer.world.requestTileInvalidateEvent([tile], now, false, true);
updatePromise.then(resolver);
}
function getCompletionCallback() {
@ -2168,18 +2191,27 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
const fallbackCompletion = getCompletionCallback();
if (!tileCacheCreated) {
// Tile-loaded not called on each tile, but only on tiles with new data! Verify we share the main cache
const origCache = tile.getCache(tile.originalCacheKey);
for (let t of origCache._tiles) {
if (!t.processing && t.cacheKey !== tile.cacheKey) {
const targetMainCache = t.getCache();
tile.addCache(t.cacheKey, targetMainCache.data, targetMainCache.type, true, false);
fallbackCompletion();
return;
}
}
}
// if (!tileCacheCreated) {
// // Tile-loaded not called on each tile, but only on tiles with new data! Verify we share the main cache
// const origCache = tile.getCache(tile.originalCacheKey);
// if (!origCache.__invStamp) {
// for (let t of origCache._tiles) {
// if (t.cacheKey !== tile.cacheKey) {
// const targetMainCache = t.getCache();
// tile.addCache(t.cacheKey, targetMainCache.data, targetMainCache.type, true, false);
// fallbackCompletion();
// return;
// }
// }
// }
// // else todo: what if we somehow managed to finish before this tile gets attached? probably impossible if the tile is joined by original cache...
// }
// // TODO ENSURE ONLY THESE TWO EVENTS CAN CALL TILE UPDATES
// // prepare for the fact that tile routine can be called here too
// tile.lastProcess = false;
// tile.processing = now;
// tile.transforming = false;
/**
* Triggered when a tile has just been loaded in memory. That means that the
@ -2197,13 +2229,24 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
* @property {OpenSeadragon.Tile} tile - The tile which has been loaded.
* @property {XMLHttpRequest} tileRequest - The AJAX request that loaded this tile (if applicable).
* @property {OpenSeadragon.Promise} - Promise resolved when the tile gets fully loaded.
* NOTE: do no await the promise in the handler: you will create a deadlock!
* @property {function} getCompletionCallback - deprecated
*/
this.viewer.raiseEventAwaiting("tile-loaded", {
tile: tile,
tiledImage: this,
tileRequest: tileRequest,
promise: finishPromise,
promise: new $.Promise(resolve => {
resolver = () => {
tile.loading = false;
tile.loaded = true;
tile.lastProcess = false;
tile.processing = false;
tile.transforming = false;
this.redraw();
resolve(tile);
};
}),
get image() {
$.console.error("[tile-loaded] event 'image' has been deprecated. Use 'tile.getData()' instead.");
return data;
@ -2219,8 +2262,6 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
},
}).catch(() => {
$.console.error("[tile-loaded] event finished with failure: there might be a problem with a plugin you are using.");
}).then(() => {
eventFinished = true;
}).then(fallbackCompletion);
},

View File

@ -265,30 +265,34 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W
* @param {Number} tStamp timestamp in milliseconds, if active timestamp of the same value is executing,
* changes are added to the cycle, else they await next iteration
* @param {Boolean} [restoreTiles=true] if true, tile processing starts from the tile original data
* @param {Boolean} [_allowTileUnloaded=false] internal flag for calling on tiles that come new to the system
* @fires OpenSeadragon.Viewer.event:tile-invalidated
* @return {OpenSeadragon.Promise<?>}
*/
requestTileInvalidateEvent: function(tilesToProcess, tStamp, restoreTiles = true) {
requestTileInvalidateEvent: function(tilesToProcess, tStamp, restoreTiles = true, _allowTileUnloaded = false) {
const tileList = [],
markedTiles = [];
for (const tile of tilesToProcess) {
// We allow re-execution on tiles that are in process but have too low processing timestamp,
// which must be solved by ensuring subsequent data calls in the suddenly outdated processing
// pipeline take no effect.
// TODO: cross writes on tile when processing cause memory errors - either ensure
// tile makes NOOP for any execution that comes with older stamp, or prevent update routine
// to happen simultanously
if (!tile || !tile.loaded || (tile.processing && tile.processing <= tStamp) || tile.transforming) {
if (!tile || (!_allowTileUnloaded && !tile.loaded) || tile.transforming) {
continue;
}
// TODO: consider locking on the original cache, which should be read only
// or lock the main cache, and compare with tile.processing tstamp
const tileCache = tile.getCache();
const tileCache = tile.getCache(tile.originalCacheKey);
if (tileCache.__invStamp && tileCache.__invStamp >= tStamp) {
continue;
}
for (let t of tileCache._tiles) {
// Mark all related tiles as processing and cache the references to unmark later on
// Mark all related tiles as processing and cache the references to unmark later on,
// last processing is set to old processing (null if finished)
t.lastProcess = t.processing;
t.processing = tStamp;
markedTiles.push(t);
}
tileCache.__invStamp = tStamp;
tileList.push(tile);
}
@ -309,11 +313,13 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W
return eventTarget.raiseEventAwaiting('tile-invalidated', {
tile: tile,
tiledImage: tile.tiledImage,
}).then(() => {
if (tile.processing === tStamp) {
}, tile.getCache(tile.originalCacheKey)).then(cacheKey => {
if (cacheKey.__invStamp === tStamp) {
// asynchronous finisher
tile.transforming = tStamp;
return tile.updateRenderTargetWithDataTransform(drawerId, supportedFormats, keepInternalCacheCopy);
return tile.updateRenderTargetWithDataTransform(drawerId, supportedFormats, keepInternalCacheCopy, tStamp).then(() => {
cacheKey.__invStamp = null;
});
}
return null;
}).catch(e => {
@ -323,7 +329,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W
return $.Promise.all(jobList).then(() => {
for (let tile of markedTiles) {
tile.lastProcess = tile.processing;
tile.lastProcess = false;
tile.processing = false;
tile.transforming = false;
}

View File

@ -47,8 +47,6 @@
}
const self = this;
this.viewer = options.viewer;
this.viewer.addHandler('tile-loaded', applyFilters);
this.viewer.addHandler('tile-invalidated', applyFilters);
// filterIncrement allows to determine whether a tile contains the
@ -67,11 +65,19 @@
}
const contextCopy = await tile.getData('context2d');
if (contextCopy.canvas.width === 0) {
debugger;
}
const currentIncrement = self.filterIncrement;
for (let i = 0; i < processors.length; i++) {
if (self.filterIncrement !== currentIncrement) {
break;
}
if (contextCopy.canvas.width === 0) {
debugger;
}
await processors[i](contextCopy);
}

View File

@ -54,18 +54,14 @@
}
if (_pA) {
viewer.removeHandler('tile-loaded', _pA);
viewer.removeHandler('tile-invalidated', _pA);
}
if (_pB) {
viewer.removeHandler('tile-loaded', _pB);
viewer.removeHandler('tile-invalidated', _pB);
}
_pA = window.pluginA;
_pB = window.pluginB;
viewer.addHandler('tile-loaded', _pA, null, window.orderPluginA || 0);
viewer.addHandler('tile-invalidated', _pA, null), window.orderPluginA || 0;
viewer.addHandler('tile-loaded', _pB, null, window.orderPluginB || 0);
viewer.addHandler('tile-invalidated', _pA, null, window.orderPluginA || 0);
viewer.addHandler('tile-invalidated', _pB, null, window.orderPluginB || 0);
viewer.requestInvalidate();
} catch (error) {
@ -119,9 +115,7 @@ window.orderPluginB = 0;
</textarea>
<textarea style="height: 120px; background-color: #e5e5e5" disabled>
// Application of the plugins done automatically:
viewer.addHandler('tile-loaded', window.pluginA, null, window.orderPluginA);
viewer.addHandler('tile-invalidated', window.pluginA, null, window.orderPluginA);
viewer.addHandler('tile-loaded', window.pluginB, null, window.orderPluginB);
viewer.addHandler('tile-invalidated', window.pluginB, null, window.orderPluginB);
viewer.requestInvalidate();
</textarea>

View File

@ -17,8 +17,10 @@
}
});
const PROMISE_REF_KEY = Symbol("_private_test_ref");
OpenSeadragon.getBuiltInDrawersForTest().forEach(testDrawer);
// If yuu want to debug a specific drawer, use instead:
// If you want to debug a specific drawer, use instead:
// ['webgl'].forEach(testDrawer);
function getPluginCode(overlayColor = "rgba(0,0,255,0.5)") {
@ -74,21 +76,31 @@
context.finish(ctx, null, "context2d");
}
});
// Get promise reference to wait for tile ready
viewer.addHandler('tile-loaded', e => {
e.tile[PROMISE_REF_KEY] = e.promise;
});
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
// we test middle of the canvas, so that we can test both tiles or the output canvas of canvas drawer :)
async function readTileData() {
async function readTileData(tileRef = null) {
// Get some time for viewer to load data
await sleep(50);
// make sure at least one tile loaded
const tile = tileRef || viewer.world.getItemAt(0).getTilesToDraw()[0];
await tile[PROMISE_REF_KEY];
// Get some time for viewer to load data
await sleep(50);
if (type === "canvas") {
//test with the underlying canvas instead
const canvas = viewer.drawer.canvas;
return viewer.drawer.canvas.getContext("2d").getImageData(canvas.width/2, canvas.height/2, 1, 1);
}
await sleep(200);
//else incompatible drawer for data getting
const tile = viewer.world.getItemAt(0).getTilesToDraw()[0];
const cache = tile.tile.getCache();
if (!cache || !cache.loaded) return null;
@ -103,12 +115,8 @@
const fnA = getPluginCode("rgba(0,0,255,1)");
const fnB = getPluginCode("rgba(255,0,0,1)");
const crashTest = () => assert.ok(false, "Tile Invalidated event should not be called");
viewer.addHandler('tile-loaded', fnA);
viewer.addHandler('tile-invalidated', crashTest);
viewer.addHandler('tile-loaded', fnB);
viewer.addHandler('tile-invalidated', crashTest);
viewer.addHandler('tile-invalidated', fnA);
viewer.addHandler('tile-invalidated', fnB);
viewer.addHandler('open', async () => {
await viewer.waitForFinishedJobsForTest();
@ -120,6 +128,7 @@
// Thorough testing of the cache state
for (let tile of viewer.tileCache._tilesLoaded) {
await tile[PROMISE_REF_KEY]; // to be sure all tiles has finished before checking
const caches = Object.entries(tile._caches);
assert.equal(caches.length, 2, `Tile ${getTileDescription(tile)} has only two caches - main & original`);
@ -142,9 +151,7 @@
const fnA = getPluginCode("rgba(0,0,255,1)");
const fnB = getPluginCode("rgba(255,0,0,1)");
viewer.addHandler('tile-loaded', fnA);
viewer.addHandler('tile-invalidated', fnA);
viewer.addHandler('tile-loaded', fnB, null, 1);
viewer.addHandler('tile-invalidated', fnB, null, 1);
// const promise = viewer.requestInvalidate();
@ -158,7 +165,6 @@
assert.equal(data.data[3], 255);
// Test swap
viewer.addHandler('tile-loaded', fnB);
viewer.addHandler('tile-invalidated', fnB);
await viewer.requestInvalidate();
@ -170,7 +176,6 @@
assert.equal(data.data[3], 255);
// Now B gets applied last! Red
viewer.addHandler('tile-loaded', fnB, null, -1);
viewer.addHandler('tile-invalidated', fnB, null, -1);
await viewer.requestInvalidate();
// no change
@ -182,6 +187,7 @@
// Thorough testing of the cache state
for (let tile of viewer.tileCache._tilesLoaded) {
await tile[PROMISE_REF_KEY]; // to be sure all tiles has finished before checking
const caches = Object.entries(tile._caches);
assert.equal(caches.length, 2, `Tile ${getTileDescription(tile)} has only two caches - main & original`);
@ -204,9 +210,7 @@
const fnA = getPluginCode("rgba(0,255,0,1)");
const fnB = getResetTileDataCode();
viewer.addHandler('tile-loaded', fnA);
viewer.addHandler('tile-invalidated', fnA);
viewer.addHandler('tile-loaded', fnB, null, 1);
viewer.addHandler('tile-invalidated', fnB, null, 1);
// const promise = viewer.requestInvalidate();
@ -220,7 +224,6 @@
assert.equal(data.data[3], 255);
// Test swap - suddenly B applied since it was added later
viewer.addHandler('tile-loaded', fnB);
viewer.addHandler('tile-invalidated', fnB);
await viewer.requestInvalidate();
data = await readTileData();
@ -229,7 +232,6 @@
assert.equal(data.data[2], 255);
assert.equal(data.data[3], 255);
viewer.addHandler('tile-loaded', fnB, null, -1);
viewer.addHandler('tile-invalidated', fnB, null, -1);
await viewer.requestInvalidate();
data = await readTileData();
@ -241,6 +243,7 @@
// Thorough testing of the cache state
for (let tile of viewer.tileCache._tilesLoaded) {
await tile[PROMISE_REF_KEY]; // to be sure all tiles has finished before checking
const caches = Object.entries(tile._caches);
assert.equal(caches.length, 1, `Tile ${getTileDescription(tile)} has only single, original cache`);