Update tests, fix async cache bug - first convert to supported format, then process. Record drawer ID in the internal cache type.

This commit is contained in:
Aiosa 2025-01-10 22:05:16 +01:00
parent 226a44c498
commit 03b7c5b9a6
2 changed files with 329 additions and 239 deletions

View File

@ -225,21 +225,17 @@
prepareForRendering(drawer) { prepareForRendering(drawer) {
const supportedTypes = drawer.getRequiredDataFormats(); const supportedTypes = drawer.getRequiredDataFormats();
if (drawer.options.usePrivateCache && drawer.options.preloadCache) { let selfPromise;
return this.prepareInternalCacheAsync(drawer).then(_ => {
// if not internal copy and we have no data, or we are ready to render, exit
if (!this.loaded || supportedTypes.includes(this.type)) {
return this.await();
}
return this.transformTo(supportedTypes);
});
}
if (!this.loaded || supportedTypes.includes(this.type)) { if (!this.loaded || supportedTypes.includes(this.type)) {
return this.await(); selfPromise = this.await();
} else {
selfPromise = this.transformTo(supportedTypes);
} }
return this.transformTo(supportedTypes);
if (drawer.options.usePrivateCache && drawer.options.preloadCache) {
return selfPromise.then(_ => this.prepareInternalCacheAsync(drawer));
}
return selfPromise;
} }
/** /**
@ -265,7 +261,9 @@
$.console.assert(this._tRef, "Data Create called from invalidation routine needs tile reference!"); $.console.assert(this._tRef, "Data Create called from invalidation routine needs tile reference!");
const transformedData = drawer.dataCreate(this, this._tRef); const transformedData = drawer.dataCreate(this, this._tRef);
$.console.assert(transformedData !== undefined, "[DrawerBase.dataCreate] must return a value if usePrivateCache is enabled!"); $.console.assert(transformedData !== undefined, "[DrawerBase.dataCreate] must return a value if usePrivateCache is enabled!");
internalCache = this[DRAWER_INTERNAL_CACHE][drawer.getId()] = new $.InternalCacheRecord(transformedData, (data) => drawer.dataFree(data)); const drawerID = drawer.getId();
internalCache = this[DRAWER_INTERNAL_CACHE][drawerID] = new $.InternalCacheRecord(transformedData,
drawerID, (data) => drawer.dataFree(data));
return internalCache.await(); return internalCache.await();
} }
@ -290,7 +288,10 @@
$.console.assert(this._tRef, "Data Create called from drawing loop needs tile reference!"); $.console.assert(this._tRef, "Data Create called from drawing loop needs tile reference!");
const transformedData = drawer.dataCreate(this, this._tRef); const transformedData = drawer.dataCreate(this, this._tRef);
$.console.assert(transformedData !== undefined, "[DrawerBase.dataCreate] must return a value if usePrivateCache is enabled!"); $.console.assert(transformedData !== undefined, "[DrawerBase.dataCreate] must return a value if usePrivateCache is enabled!");
internalCache = this[DRAWER_INTERNAL_CACHE][drawer.getId()] = new $.InternalCacheRecord(transformedData, (data) => drawer.dataFree(data));
const drawerID = drawer.getId();
internalCache = this[DRAWER_INTERNAL_CACHE][drawerID] = new $.InternalCacheRecord(transformedData,
drawerID, (data) => drawer.dataFree(data));
return internalCache; return internalCache;
} }
@ -676,9 +677,10 @@
* @private * @private
*/ */
$.InternalCacheRecord = class { $.InternalCacheRecord = class {
constructor(data, onDestroy) { constructor(data, type, onDestroy) {
this.tstamp = $.now(); this.tstamp = $.now();
this._ondestroy = onDestroy; this._ondestroy = onDestroy;
this._type = type;
if (data instanceof $.Promise) { if (data instanceof $.Promise) {
this._promise = data; this._promise = data;
@ -706,7 +708,7 @@
* @returns {string} * @returns {string}
*/ */
get type() { get type() {
return "__internal_cache__"; return this._type;
} }
/** /**
@ -1167,11 +1169,15 @@
*/ */
clearDrawerInternalCache(drawer) { clearDrawerInternalCache(drawer) {
const drawerId = drawer.getId(); const drawerId = drawer.getId();
for (let zombie in this._zombiesLoaded) { for (let zombie of this._zombiesLoaded) {
this._zombiesLoaded[zombie].destroyInternalCache(drawerId); if (zombie) {
zombie.destroyInternalCache(drawerId);
}
} }
for (let cache in this._tilesLoaded) { for (let cache of this._cachesLoaded) {
this._tilesLoaded[cache].destroyInternalCache(drawerId); if (cache) {
cache.destroyInternalCache(drawerId);
}
} }
} }

View File

@ -92,21 +92,6 @@
this.testEvents = new OpenSeadragon.EventSource(); this.testEvents = new OpenSeadragon.EventSource();
} }
getType() {
return "test-cache-drawer";
}
// Make test use private cache
get defaultOptions() {
return {
usePrivateCache: true
};
}
getSupportedDataFormats() {
return [T_C, T_E];
}
static isSupported() { static isSupported() {
return true; return true;
} }
@ -128,12 +113,16 @@
} }
} }
dataFree(data) {
this.testEvents.raiseEvent('free-data');
}
canRotate() { canRotate() {
return true; return true;
} }
destroy() { destroy() {
//noop this.destroyInternalCache();
} }
setImageSmoothingEnabled(imageSmoothingEnabled){ setImageSmoothingEnabled(imageSmoothingEnabled){
@ -149,6 +138,60 @@
} }
} }
OpenSeadragon.SyncInternalCacheDrawer = class extends OpenSeadragon.TestCacheDrawer {
getType() {
return "test-cache-drawer-sync";
}
getSupportedDataFormats() {
return [T_C, T_E];
}
// Make test use private cache
get defaultOptions() {
return {
usePrivateCache: true,
preloadCache: false,
};
}
dataCreate(cache, tile) {
this.testEvents.raiseEvent('create-data');
return cache.data;
}
}
OpenSeadragon.AsnycInternalCacheDrawer = class extends OpenSeadragon.TestCacheDrawer {
getType() {
return "test-cache-drawer-async";
}
getSupportedDataFormats() {
return [T_A];
}
// Make test use private cache
get defaultOptions() {
return {
usePrivateCache: true,
preloadCache: true,
};
}
dataCreate(cache, tile) {
this.testEvents.raiseEvent('create-data');
return cache.getDataAs(T_C, true);
}
dataFree(data) {
super.dataFree(data);
// Be nice and truly destroy the data copy
OpenSeadragon.convertor.destroy(data, T_C);
}
}
OpenSeadragon.EmptyTestT_ATileSource = class extends OpenSeadragon.TileSource { OpenSeadragon.EmptyTestT_ATileSource = class extends OpenSeadragon.TileSource {
supports( data, url ){ supports( data, url ){
@ -184,14 +227,6 @@
$('<div id="example"></div>').appendTo("#qunit-fixture"); $('<div id="example"></div>').appendTo("#qunit-fixture");
testLog.reset(); testLog.reset();
viewer = OpenSeadragon({
id: 'example',
prefixUrl: '/build/openseadragon/images/',
maxImageCacheCount: 200, //should be enough to fit test inside the cache
springStiffness: 100, // Faster animation = faster tests
drawer: 'test-cache-drawer',
});
OpenSeadragon.ImageLoader.prototype.addJob = originalJob; OpenSeadragon.ImageLoader.prototype.addJob = originalJob;
// Reset counters // Reset counters
@ -313,201 +348,234 @@
done(); done();
}); });
//Tile API and cache interaction // Tile API and cache interaction
// QUnit.test('Tile: basic rendering & test setup', function(test) { QUnit.test('Tile: basic rendering & test setup (sync drawer)', function(test) {
// const done = test.async(); const done = test.async();
//
// const tileCache = viewer.tileCache;
// const drawer = viewer.drawer;
//
// let testTileCalled = false;
// drawer.testEvents.addHandler('test-tile', e => {
// testTileCalled = true;
// test.ok(e.dataToDraw, "Tile data is ready to be drawn");
// });
//
// viewer.addHandler('open', async () => {
// await viewer.waitForFinishedJobsForTest();
// await sleep(1); // necessary to make space for a draw call
//
// test.ok(viewer.world.getItemAt(0).source instanceof OpenSeadragon.EmptyTestT_ATileSource, "Tests are done with empty test source type T_A.");
// test.ok(viewer.world.getItemAt(1).source instanceof OpenSeadragon.EmptyTestT_ATileSource, "Tests are done with empty test source type T_A.");
// test.ok(testTileCalled, "Drawer tested at least one tile.");
//
// test.ok(typeAtoB > 1, "At least one conversion was triggered.");
// test.equal(typeAtoB, typeBtoC, "A->B = B->C, since we need to move all data to T_C for the drawer.");
//
// for (let tile of tileCache._tilesLoaded) {
// const cache = tile.getCache();
// test.equal(cache.type, T_A, "Cache data was not affected, the drawer uses internal cache.");
//
// const internalCache = cache.getCacheForRendering(drawer, tile);
// test.equal(internalCache.type, T_C, "Conversion A->C ready, since there is no way to get to T_E.");
// test.ok(internalCache.loaded, "Internal cache ready.");
// }
//
// done();
// });
// viewer.open([
// {isTestSource: true},
// {isTestSource: true},
// ]);
// });
// QUnit.test('Tile & Invalidation API: basic conversion & preprocessing', function(test) { viewer = OpenSeadragon({
// const done = test.async(); id: 'example',
// prefixUrl: '/build/openseadragon/images/',
// const tileCache = viewer.tileCache; maxImageCacheCount: 200, //should be enough to fit test inside the cache
// const drawer = viewer.drawer; springStiffness: 100, // Faster animation = faster tests
// drawer: 'test-cache-drawer-sync',
// let testTileCalled = false; });
//
// let _currentTestVal = undefined; const tileCache = viewer.tileCache;
// let previousTestValue = undefined; const drawer = viewer.drawer;
// drawer.testEvents.addHandler('test-tile', e => {
// test.ok(e.dataToDraw, "Tile data is ready to be drawn"); let testTileCalled = false;
// if (_currentTestVal !== undefined) { let countFreeCalled = 0;
// testTileCalled = true; let countCreateCalled = 0;
// test.equal(e.dataToDraw, _currentTestVal, "Value is correct on the drawn data."); drawer.testEvents.addHandler('test-tile', e => {
// } testTileCalled = true;
// }); test.ok(e.dataToDraw, "Tile data is ready to be drawn");
// });
// function testDrawingRoutine(value) { drawer.testEvents.addHandler('create-data', e => {
// _currentTestVal = value; countCreateCalled++;
// viewer.world.needsDraw(); });
// viewer.world.draw(); drawer.testEvents.addHandler('free-data', e => {
// previousTestValue = value; countFreeCalled++;
// _currentTestVal = undefined; });
// }
// viewer.addHandler('open', async () => {
// viewer.addHandler('open', async () => { await viewer.waitForFinishedJobsForTest();
// await viewer.waitForFinishedJobsForTest(); await sleep(1); // necessary to make space for a draw call
// await sleep(1); // necessary to make space for a draw call
// test.ok(viewer.world.getItemAt(0).source instanceof OpenSeadragon.EmptyTestT_ATileSource, "Tests are done with empty test source type T_A.");
// // Test simple data set -> creates main cache test.ok(viewer.world.getItemAt(1).source instanceof OpenSeadragon.EmptyTestT_ATileSource, "Tests are done with empty test source type T_A.");
// test.ok(testTileCalled, "Drawer tested at least one tile.");
// let testHandler = async e => {
// // data comes in as T_A test.ok(typeAtoB > 1, "At least one conversion was triggered.");
// test.equal(typeDtoA, 0, "No conversion needed to get type A."); test.equal(typeAtoB, typeBtoC, "A->B = B->C, since we need to move all data to T_C for the drawer.");
// test.equal(typeCtoA, 0, "No conversion needed to get type A.");
// for (let tile of tileCache._tilesLoaded) {
// const data = await e.getData(T_A); const cache = tile.getCache();
// test.equal(data, 1, "Copy: creation of a working cache."); test.equal(cache.type, T_C, "Cache data was affected, the drawer supports only T_C since there is no way to get to T_E.");
// e.tile.__TEST_PROCESSED = true;
// const internalCache = cache.getDataForRendering(drawer, tile);
// // Test value 2 since we set T_C no need to convert test.equal(internalCache.type, viewer.drawer.getId(), "Sync conversion routine means T_C is also internal since dataCreate only creates data. However, internal cache keeps type of the drawer ID.");
// await e.setData(2, T_C); test.ok(internalCache.loaded, "Internal cache ready.");
// test.notOk(e.outdated(), "Event is still valid."); }
// };
// test.ok(countCreateCalled > 0, "Internal cache creation called.");
// viewer.addHandler('tile-invalidated', testHandler); viewer.drawer.destroyInternalCache();
// await viewer.world.requestInvalidate(true); test.equal(countCreateCalled, countFreeCalled, "Free called as many times as create.");
// await sleep(1); // necessary to make space for internal updates
// testDrawingRoutine(2); done();
// });
// //test for each level only single cache was processed viewer.open([
// const processedLevels = {}; {isTestSource: true},
// for (let tile of tileCache._tilesLoaded) { {isTestSource: true},
// const level = tile.level; ]);
// });
// if (tile.__TEST_PROCESSED) {
// test.ok(!processedLevels[level], "Only single tile processed per level."); QUnit.test('Tile & Invalidation API: basic conversion & preprocessing', function(test) {
// processedLevels[level] = true; const done = test.async();
// delete tile.__TEST_PROCESSED;
// } viewer = OpenSeadragon({
// id: 'example',
// const origCache = tile.getCache(tile.originalCacheKey); prefixUrl: '/build/openseadragon/images/',
// test.equal(origCache.type, T_A, "Original cache data was not affected, the drawer uses internal cache."); maxImageCacheCount: 200, //should be enough to fit test inside the cache
// test.equal(origCache.data, 0, "Original cache data was not affected, the drawer uses internal cache."); springStiffness: 100, // Faster animation = faster tests
// drawer: 'test-cache-drawer-async',
// const cache = tile.getCache(); });
// test.equal(cache.type, T_C, "Main Cache Updated (suite 1)"); const tileCache = viewer.tileCache;
// test.equal(cache.data, previousTestValue, "Main Cache Updated (suite 1)"); const drawer = viewer.drawer;
//
// const internalCache = cache.getCacheForRendering(drawer, tile); let testTileCalled = false;
// test.equal(T_C, internalCache.type, "Conversion A->C ready, since there is no way to get to T_E.");
// test.ok(internalCache.loaded, "Internal cache ready."); let _currentTestVal = undefined;
// } let previousTestValue = undefined;
// drawer.testEvents.addHandler('test-tile', e => {
// // Test that basic scenario with reset data false starts from the main cache data of previous round test.ok(e.dataToDraw, "Tile data is ready to be drawn");
// const modificationConstant = 50; if (_currentTestVal !== undefined) {
// viewer.removeHandler('tile-invalidated', testHandler); testTileCalled = true;
// testHandler = async e => { test.equal(e.dataToDraw, _currentTestVal, "Value is correct on the drawn data.");
// const data = await e.getData(T_B); }
// test.equal(data, previousTestValue + 2, "C -> A -> B conversion happened."); });
// await e.setData(data + modificationConstant, T_B);
// console.log(data + modificationConstant); function testDrawingRoutine(value) {
// test.notOk(e.outdated(), "Event is still valid."); _currentTestVal = value;
// }; viewer.world.needsDraw();
// console.log(previousTestValue, modificationConstant) viewer.world.draw();
// _currentTestVal = undefined;
// viewer.addHandler('tile-invalidated', testHandler); }
// await viewer.world.requestInvalidate(false);
// await sleep(1); // necessary to make space for a draw call viewer.addHandler('open', async () => {
// // We set data as TB - there is T_C -> T_A -> T_B -> T_C conversion round await viewer.waitForFinishedJobsForTest();
// let newValue = previousTestValue + modificationConstant + 3; await sleep(1); // necessary to make space for a draw call
// testDrawingRoutine(newValue);
// // Test simple data set -> creates main cache
// newValue--; // intenrla cache performed +1 conversion, but here we have main cache with one step less
// for (let tile of tileCache._tilesLoaded) { let testHandler = async e => {
// const cache = tile.getCache(); // data comes in as T_A
// test.equal(cache.type, T_B, "Main Cache Updated (suite 2)."); test.equal(typeDtoA, 0, "No conversion needed to get type A.");
// test.equal(cache.data, newValue, "Main Cache Updated (suite 2)."); test.equal(typeCtoA, 0, "No conversion needed to get type A.");
// }
// const data = await e.getData(T_A);
// // Now test whether data reset works, value 1 -> copy perfomed due to internal cache cration test.equal(data, 1, "Copy: creation of a working cache.");
// viewer.removeHandler('tile-invalidated', testHandler); e.tile.__TEST_PROCESSED = true;
// testHandler = async e => {
// const data = await e.getData(T_B); // Test value 2 since we set T_C no need to convert
// test.equal(data, 1, "Copy: creation of a working cache."); await e.setData(2, T_C);
// await e.setData(-8, T_E); test.notOk(e.outdated(), "Event is still valid.");
// e.resetData(); };
// };
// viewer.addHandler('tile-invalidated', testHandler); viewer.addHandler('tile-invalidated', testHandler);
// await viewer.world.requestInvalidate(true); await viewer.world.requestInvalidate(true);
// await sleep(1); // necessary to make space for a draw call
// testDrawingRoutine(2); // Value +2 rendering from original data //test for each level only single cache was processed
// const processedLevels = {};
// for (let tile of tileCache._tilesLoaded) { for (let tile of tileCache._tilesLoaded) {
// const origCache = tile.getCache(tile.originalCacheKey); const level = tile.level;
// test.ok(tile.getCache() === origCache, "Main cache is now original cache.");
// } if (tile.__TEST_PROCESSED) {
// test.ok(!processedLevels[level], "Only single tile processed per level.");
// // Now force main cache creation that differs processedLevels[level] = true;
// viewer.removeHandler('tile-invalidated', testHandler); delete tile.__TEST_PROCESSED;
// testHandler = async e => { }
// await e.setData(41, T_B);
// }; const origCache = tile.getCache(tile.originalCacheKey);
// viewer.addHandler('tile-invalidated', testHandler); test.equal(origCache.type, T_A, "Original cache data was not affected, the drawer uses internal cache.");
// await viewer.world.requestInvalidate(true); test.equal(origCache.data, 0, "Original cache data was not affected, the drawer uses internal cache.");
//
// // Now test whether data reset works, even with non-original data const cache = tile.getCache();
// viewer.removeHandler('tile-invalidated', testHandler); test.equal(cache.type, T_A, "Main Cache Converted T_C -> T_A (drawer supports type A) (suite 1)");
// testHandler = async e => { test.equal(cache.data, 3, "Conversion step increases plugin-stored value 2 to 3");
// const data = await e.getData(T_B);
// test.equal(data, 42, "Copy: 41 + 1."); const internalCache = cache.getDataForRendering(drawer, tile);
// await e.setData(data, T_E); test.equal(internalCache.type, viewer.drawer.getId(), "Internal cache has type of the drawer ID.");
// e.resetData(); test.ok(internalCache.loaded, "Internal cache ready.");
// }; }
// viewer.addHandler('tile-invalidated', testHandler); // Internal cache will have value 5: main cache is 3, type is T_A,
// await viewer.world.requestInvalidate(false); testDrawingRoutine(5); // internal cache transforms to T_C: two steps, TA->TB->TC 3+2
// await sleep(1); // necessary to make space for a draw call
// testDrawingRoutine(42); // Test that basic scenario with reset data false starts from the main cache data of previous round
// const modificationConstant = 50;
// for (let tile of tileCache._tilesLoaded) { viewer.removeHandler('tile-invalidated', testHandler);
// const origCache = tile.getCache(tile.originalCacheKey); testHandler = async e => {
// test.equal(origCache.type, T_A, "Original cache data was not affected, the drawer uses main cache even after refresh."); const data = await e.getData(T_B);
// test.equal(origCache.data, 0, "Original cache data was not affected, the drawer uses main cache even after refresh."); test.equal(data, 4, "A -> B conversion happened, we started from value 3 in the main cache.");
// } await e.setData(data + modificationConstant, T_B);
// test.notOk(e.outdated(), "Event is still valid.");
// test.ok(testTileCalled, "Drawer tested at least one tile."); };
// done();
// }); viewer.addHandler('tile-invalidated', testHandler);
// viewer.open([ await viewer.world.requestInvalidate(false);
// {isTestSource: true},
// {isTestSource: true}, // We set data as TB - there is required T_A: T_B -> T_C -> T_A conversion round on the main cache
// ]); let newValue = modificationConstant + 4 + 2;
// }); // and there is still requirement of T_C on internal data, +2 steps
testDrawingRoutine(newValue + 2);
for (let tile of tileCache._tilesLoaded) {
const cache = tile.getCache();
test.equal(cache.type, T_A, "Main Cache Updated (suite 2).");
test.equal(cache.data, newValue, "Main Cache Updated (suite 2).");
}
// Now test whether data reset works, value 1 -> copy perfomed due to internal cache cration
viewer.removeHandler('tile-invalidated', testHandler);
testHandler = async e => {
const data = await e.getData(T_B);
test.equal(data, 1, "Copy: creation of a working cache.");
await e.setData(-8, T_E);
e.resetData();
};
viewer.addHandler('tile-invalidated', testHandler);
await viewer.world.requestInvalidate(true);
await sleep(1); // necessary to make space for a draw call
testDrawingRoutine(2); // Value +2 rendering from original data
for (let tile of tileCache._tilesLoaded) {
const origCache = tile.getCache(tile.originalCacheKey);
test.ok(tile.getCache() === origCache, "Main cache is now original cache.");
}
// Now force main cache creation that differs
viewer.removeHandler('tile-invalidated', testHandler);
testHandler = async e => {
await e.setData(41, T_B);
};
viewer.addHandler('tile-invalidated', testHandler);
await viewer.world.requestInvalidate(true);
// Now test whether data reset works, even with non-original data
viewer.removeHandler('tile-invalidated', testHandler);
testHandler = async e => {
const data = await e.getData(T_B);
test.equal(data, 44, "Copy: 41 +2 (previous request invalidate ends at T_A) + 1 (we request type B).");
await e.setData(data, T_E); // there is no way to convert T_E -> T_A, this would throw an error
e.resetData(); // reset data will revert to original cache
};
viewer.addHandler('tile-invalidated', testHandler);
// The data will be 45 since no change has been made:
// last main cache set was 41 T_B, supported T_A = +2
// and internal requirement T_C = +2
const checkNotCalled = e => {
test.ok(false, "Create data must not be called when there is no change!");
};
drawer.testEvents.addHandler('create-data', checkNotCalled);
await viewer.world.requestInvalidate(false);
testDrawingRoutine(45);
for (let tile of tileCache._tilesLoaded) {
const origCache = tile.getCache(tile.originalCacheKey);
test.equal(origCache.type, T_A, "Original cache data was not affected, the drawer uses main cache even after refresh.");
test.equal(origCache.data, 0, "Original cache data was not affected, the drawer uses main cache even after refresh.");
}
test.ok(testTileCalled, "Drawer tested at least one tile.");
viewer.destroy();
done();
});
viewer.open([
{isTestSource: true},
{isTestSource: true},
]);
});
//Tile API and cache interaction //Tile API and cache interaction
QUnit.test('Tile API Cache Interaction', function(test) { QUnit.test('Tile API Cache Interaction', function(test) {
@ -632,6 +700,14 @@
QUnit.test('Zombie Cache', function(test) { QUnit.test('Zombie Cache', function(test) {
const done = test.async(); const done = test.async();
viewer = OpenSeadragon({
id: 'example',
prefixUrl: '/build/openseadragon/images/',
maxImageCacheCount: 200, //should be enough to fit test inside the cache
springStiffness: 100, // Faster animation = faster tests
drawer: 'test-cache-drawer-sync',
});
//test jobs by coverage: fail if cached coverage not fully re-stored without jobs //test jobs by coverage: fail if cached coverage not fully re-stored without jobs
let jobCounter = 0, coverage = undefined; let jobCounter = 0, coverage = undefined;
OpenSeadragon.ImageLoader.prototype.addJob = function (options) { OpenSeadragon.ImageLoader.prototype.addJob = function (options) {
@ -711,6 +787,14 @@
QUnit.test('Zombie Cache Replace Item', function(test) { QUnit.test('Zombie Cache Replace Item', function(test) {
const done = test.async(); const done = test.async();
viewer = OpenSeadragon({
id: 'example',
prefixUrl: '/build/openseadragon/images/',
maxImageCacheCount: 200, //should be enough to fit test inside the cache
springStiffness: 100, // Faster animation = faster tests
drawer: 'test-cache-drawer-sync',
});
let jobCounter = 0, coverage = undefined; let jobCounter = 0, coverage = undefined;
OpenSeadragon.ImageLoader.prototype.addJob = function (options) { OpenSeadragon.ImageLoader.prototype.addJob = function (options) {
jobCounter++; jobCounter++;