Integration tests: bugfixing of manipulation of tiles that share data: when tiles are loaded, when tiles are processed, also await async data preparation befre finishing the invalidation event.

This commit is contained in:
Aiosa 2024-10-22 17:25:02 +02:00
parent e403e29312
commit 20177116e7
12 changed files with 383 additions and 68 deletions

View File

@ -146,8 +146,7 @@ class HTMLDrawer extends OpenSeadragon.DrawerBase{
* @returns {Element} the div to draw into
*/
_createDrawingElement(){
let canvas = $.makeNeutralElement("div");
return canvas;
return $.makeNeutralElement("div");
}
/**
@ -259,6 +258,10 @@ class HTMLDrawer extends OpenSeadragon.DrawerBase{
// content during animation of the container size.
const dataObject = this.getDataToDraw(tile);
if (!dataObject) {
return;
}
if ( dataObject.element.parentNode !== container ) {
container.appendChild( dataObject.element );
}

View File

@ -2367,6 +2367,14 @@ function OpenSeadragon( options ){
},
/**
* Makes an AJAX request.
* @param {String} url - the url to request
* @param {Function} onSuccess
* @param {Function} onError
* @throws {Error}
* @returns {XMLHttpRequest}
* @deprecated deprecated way of calling this function
*//**
* Makes an AJAX request.
* @param {Object} options
* @param {String} options.url - the url to request
@ -2379,14 +2387,6 @@ function OpenSeadragon( options ){
* @param {Boolean} [options.withCredentials=false] - whether to set the XHR's withCredentials
* @throws {Error}
* @returns {XMLHttpRequest}
*//**
* Makes an AJAX request.
* @param {String} url - the url to request
* @param {Function} onSuccess
* @param {Function} onError
* @throws {Error}
* @returns {XMLHttpRequest}
* @deprecated deprecated way of calling this function
*/
makeAjaxRequest: function( url, onSuccess, onError ) {
var withCredentials;

View File

@ -550,7 +550,10 @@ $.Tile.prototype = {
/**
* Optimizazion: prepare target cache for subsequent use in rendering, and perform updateRenderTarget()
* The main idea of this function is that it must be ASYNCHRONOUS since there might be additional processing
* happening due to underlying drawer requirements.
* @private
* @return {OpenSeadragon.Promise<?>}
*/
updateRenderTargetWithDataTransform: function (drawerId, supportedFormats, usePrivateCache) {
// Now, if working cache exists, we set main cache to the working cache --> prepare
@ -570,17 +573,20 @@ $.Tile.prototype = {
return cache.prepareForRendering(drawerId, supportedFormats, usePrivateCache, this.processing);
}
return null;
return $.Promise.resolve();
},
/**
* Resolves render target: changes might've been made to the rendering pipeline:
* - working cache is set: make sure main cache will be replaced
* - working cache is unset: make sure main cache either gets updated to original data or stays (based on this.__restore)
*
* The main idea of this function is that it is SYNCHRONOUS, e.g. can perform in-place cache swap to update
* before any rendering occurs.
* @private
* @return
*/
updateRenderTarget: function () {
updateRenderTarget: function (_allowTileNotLoaded = false) {
// Check if we asked for restore, and make sure we set it to false since we update the whole cache state
const requestedRestore = this.__restore;
this.__restore = false;
@ -593,7 +599,8 @@ $.Tile.prototype = {
this.tiledImage._tileCache.consumeCache({
tile: this,
victimKey: this._wcKey,
consumerKey: newCacheKey
consumerKey: newCacheKey,
tileAllowNotLoaded: _allowTileNotLoaded
});
this.cacheKey = newCacheKey;
return;
@ -831,7 +838,6 @@ $.Tile.prototype = {
}
// Working key is never updated, it will be invalidated (but do not dereference cache, just fix the pointers)
this._caches[newKey] = cache;
cache.AAA = true;
delete this._caches[oldKey];
},

View File

@ -238,6 +238,7 @@
}
}
}
return this;
};
// if not internal copy and we have no data, or we are ready to render, exit
@ -273,8 +274,7 @@
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
fin();
return this;
return fin();
});
}
@ -372,11 +372,10 @@
// make sure this gets destroyed even if loaded=false
if (this.loaded) {
this._destroySelfUnsafe(this._data, this._type);
} else {
} else if (this._promise) {
const oldType = this._type;
this._promise.then(x => this._destroySelfUnsafe(x, oldType));
}
this.loaded = false;
}
}
@ -389,6 +388,7 @@
if (!this._destroyed) {
return;
}
this.loaded = false;
this._tiles = null;
this._data = null;
this._type = null;
@ -922,12 +922,14 @@
* inheriting its tiles and key.
* @param {String} options.consumerKey - The cache that consumes the victim. In fact, it gets destroyed and
* replaced by victim, which inherits all its metadata.
* @param {}
* @param {Boolean} options.tileAllowNotLoaded - if true, tile that is not loaded is also processed,
* this is internal parameter used in tile-loaded completion routine, as we need to prepare tile but
* it is not yet loaded and cannot be marked as so (otherwise the system would think it is ready)
*/
consumeCache(options) {
const victim = this._cachesLoaded[options.victimKey],
tile = options.tile;
if (!victim || (!tile.loaded && !tile.loading)) {
if (!victim || (!options.tileAllowNotLoaded && !tile.loaded && !tile.loading)) {
$.console.warn("Attempt to consume non-existent cache: this is probably a bug!");
return;
}
@ -935,12 +937,14 @@
let tiles = [...tile.getCache()._tiles];
if (consumer) {
// We need to avoid costly conversions: replace consumer.
// unloadCacheForTile() will modify the array, iterate over a copy
const iterateTiles = [...consumer._tiles];
// 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) {
this.unloadCacheForTile(tile, options.consumerKey, true);
}
}
}
// Just swap victim to become new consumer
const resultCache = this.renameCache({
@ -951,8 +955,10 @@
if (resultCache) {
// 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()
// Loading tiles are also accepted, since they might be in the process of finishing. However,
// note that they are not part of the unloading process above!
for (let tile of tiles) {
if (tile !== options.tile && tile.loaded) {
if (tile !== options.tile && (tile.loaded || tile.loading)) {
tile.addCache(options.consumerKey, resultCache.data, resultCache.type, true, false);
}
}

View File

@ -291,7 +291,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
requestInvalidate: function (viewportOnly, tStamp, restoreTiles = true) {
tStamp = tStamp || $.now();
const tiles = viewportOnly ? this._lastDrawn.map(x => x.tile) : this._tileCache.getLoadedTilesFor(this);
this.viewer.world.requestTileInvalidateEvent(tiles, tStamp, restoreTiles);
return this.viewer.world.requestTileInvalidateEvent(tiles, tStamp, restoreTiles);
},
/**
@ -2106,7 +2106,12 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
_setTileLoaded: function(tile, data, cutoff, tileRequest, dataType) {
tile.tiledImage = this; //unloaded with tile.unload(), so we need to set it back
// does nothing if tile.cacheKey already present
tile.addCache(tile.cacheKey, data, dataType, false, false);
let tileCacheCreated = false;
tile.addCache(tile.cacheKey, () => {
tileCacheCreated = true;
return data;
}, dataType, false, false);
let resolver = null,
increment = 0,
@ -2125,7 +2130,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
tile.hasTransparency = tile.hasTransparency || _this.source.hasTransparency(
undefined, tile.getUrl(), tile.ajaxHeaders, tile.postData
);
tile.updateRenderTarget();
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();
@ -2162,6 +2167,16 @@ $.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
// We could attempt to initialize the tile here (e.g. find another tile that has same key and if
// we find it in different main cache, we try to share it with current tile, but this process
// is also happening within tile cache logics (see last part of consumeCache(..)).
fallbackCompletion();
return;
}
/**
* Triggered when a tile has just been loaded in memory. That means that the
* image has been downloaded and can be modified before being drawn to the canvas.

View File

@ -769,18 +769,21 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype,
* @function
* @param {Boolean} [restoreTiles=true] if true, tile processing starts from the tile original data
* @fires OpenSeadragon.Viewer.event:tile-invalidated
* @return {OpenSeadragon.Promise<?>}
*/
requestInvalidate: function (restoreTiles = true) {
if ( !THIS[ this.hash ] ) {
//this viewer has already been destroyed: returning immediately
return;
return $.Promise.resolve();
}
const tStamp = $.now();
this.world.requestInvalidate(tStamp, restoreTiles);
if (this.navigator) {
this.navigator.world.requestInvalidate(tStamp, restoreTiles);
const worldPromise = this.world.requestInvalidate(tStamp, restoreTiles);
if (!this.navigator) {
return worldPromise;
}
const navigatorPromise = this.navigator.world.requestInvalidate(tStamp, restoreTiles);
return $.Promise.all([worldPromise, navigatorPromise]);
},

View File

@ -242,16 +242,16 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W
* @param {Boolean} [restoreTiles=true] if true, tile processing starts from the tile original data
* @function
* @fires OpenSeadragon.Viewer.event:tile-invalidated
* @return {OpenSeadragon.Promise<?>}
*/
requestInvalidate: function (tStamp, restoreTiles = true) {
$.__updated = tStamp = tStamp || $.now();
for ( let i = 0; i < this._items.length; i++ ) {
this._items[i].requestInvalidate(true, tStamp, restoreTiles);
}
const promises = this._items.map(item => item.requestInvalidate(true, tStamp, restoreTiles));
const tiles = this.viewer.tileCache.getLoadedTilesFor(true);
// Delay processing of all tiles of all items to a later stage by increasing tstamp
this.requestTileInvalidateEvent(tiles, tStamp, restoreTiles);
promises.push(this.requestTileInvalidateEvent(tiles, tStamp, restoreTiles));
return $.Promise.all(promises);
},
/**
@ -263,42 +263,20 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W
* 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
* @fires OpenSeadragon.Viewer.event:tile-invalidated
* @return {OpenSeadragon.Promise<?>}
*/
requestTileInvalidateEvent: function(tileList, tStamp, restoreTiles = true) {
if (tileList.length < 1) {
return;
return $.Promise.resolve();
}
if (this._queuedInvalidateTiles.length) {
this._queuedInvalidateTiles.push(tileList);
return;
return $.Promise.resolve();
}
// this.viewer.viewer is defined in navigator, ensure we call event on the parent viewer
const eventTarget = this.viewer.viewer || this.viewer;
const finish = () => {
for (let tile of tileList) {
// pass update stamp on the new cache object to avoid needless updates
const newCache = tile.getCache();
if (newCache) {
newCache._updateStamp = tStamp;
for (let t of newCache._tiles) {
// Mark all as processing
t.processing = false;
}
}
}
if (this._queuedInvalidateTiles.length) {
// Make space for other logics execution before we continue in processing
let list = this._queuedInvalidateTiles.splice(0, 1)[0];
this.requestTileInvalidateEvent(list, tStamp, restoreTiles);
} else {
this.draw();
}
};
const supportedFormats = eventTarget.drawer.getSupportedDataFormats();
const keepInternalCacheCopy = eventTarget.drawer.options.usePrivateCache;
const drawerId = eventTarget.drawer.getId();
@ -320,7 +298,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W
return true;
});
$.Promise.all(tileList.map(tile => {
const jobList = tileList.map(tile => {
if (restoreTiles) {
tile.restore();
}
@ -328,9 +306,31 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W
tile: tile,
tiledImage: tile.tiledImage,
}).then(() => {
tile.updateRenderTargetWithDataTransform(drawerId, supportedFormats, keepInternalCacheCopy);
// asynchronous finisher
return tile.updateRenderTargetWithDataTransform(drawerId, supportedFormats, keepInternalCacheCopy);
}).catch(e => {
$.console.error("Update routine error:", e);
});
});
return $.Promise.all(jobList).then(() => {
for (let tile of tileList) {
// pass update stamp on the new cache object to avoid needless updates
const newCache = tile.getCache();
if (newCache) {
newCache._updateStamp = tStamp;
tile.processing = false;
}
}
if (this._queuedInvalidateTiles.length) {
// Make space for other logics execution before we continue in processing
let list = this._queuedInvalidateTiles.splice(0, 1)[0];
this.requestTileInvalidateEvent(list, tStamp, restoreTiles);
} else {
this.draw();
}
});
})).catch(finish).then(finish);
},
/**

View File

@ -240,5 +240,29 @@
};
OpenSeadragon.console = testConsole;
OpenSeadragon.getBuiltInDrawersForTest = function() {
const drawers = [];
for (let property in OpenSeadragon) {
const drawer = OpenSeadragon[ property ],
proto = drawer.prototype;
if( proto &&
proto instanceof OpenSeadragon.DrawerBase &&
$.isFunction( proto.getType )){
drawers.push(proto.getType.call( drawer ));
}
}
return drawers;
};
OpenSeadragon.Viewer.prototype.waitForFinishedJobsForTest = function () {
let finish;
let int = setInterval(() => {
if (this.imageLoader.jobsInProgress < 1) {
finish();
}
}, 50);
return new OpenSeadragon.Promise((resolve) => finish = resolve);
};
} )();

View File

@ -0,0 +1,259 @@
/* global QUnit, testLog */
(function() {
let viewer;
QUnit.module(`Data Manipulation Across Drawers`, {
beforeEach: function () {
$('<div id="example"></div>').appendTo("#qunit-fixture");
testLog.reset();
},
afterEach: function () {
if (viewer && viewer.close) {
viewer.close();
}
viewer = null;
}
});
OpenSeadragon.getBuiltInDrawersForTest().forEach(testDrawer);
// If yuu want to debug a specific drawer, use instead:
// ['webgl'].forEach(testDrawer);
function getPluginCode(overlayColor = "rgba(0,0,255,0.5)") {
return async function(e) {
const tile = e.tile;
const ctx = await tile.getData('context2d'), canvas = ctx.canvas;
ctx.fillStyle = overlayColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
await tile.setData(ctx, 'context2d');
};
}
function getResetTileDataCode() {
return async function(e) {
const tile = e.tile;
tile.restore();
};
}
function getTileDescription(t) {
return `${t.level}/${t.x}-${t.y}`;
}
function testDrawer(type) {
function whiteViewport() {
viewer = OpenSeadragon({
id: 'example',
prefixUrl: '/build/openseadragon/images/',
maxImageCacheCount: 200,
springStiffness: 100,
drawer: type
});
viewer.open({
width: 24,
height: 24,
tileSize: 24,
minLevel: 1,
// This is a crucial test feature: all tiles share the same URL, so there are plenty collisions
getTileUrl: (x, y, l) => "",
getTilePostData: () => "",
downloadTileStart: (context) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = context.tile.size.x;
canvas.height = context.tile.size.y;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, context.tile.size.x, context.tile.size.y);
context.finish(ctx, null, "context2d");
}
});
}
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() {
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;
const ctx = await cache.getDataAs("context2d");
if (!ctx) return null;
return ctx.getImageData(ctx.canvas.width/2, ctx.canvas.height/2, 1, 1)
}
QUnit.test(type + ' drawer: basic scenario.', function(assert) {
whiteViewport();
const done = assert.async();
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('open', async () => {
await viewer.waitForFinishedJobsForTest();
let data = await readTileData();
assert.equal(data.data[0], 255);
assert.equal(data.data[1], 0);
assert.equal(data.data[2], 0);
assert.equal(data.data[3], 255);
// Thorough testing of the cache state
for (let tile of viewer.tileCache._tilesLoaded) {
const caches = Object.entries(tile._caches);
assert.equal(caches.length, 2, `Tile ${getTileDescription(tile)} has only two caches - main & original`);
for (let [key, value] of caches) {
assert.ok(value.loaded, `Attached cache '${key}' is ready.`);
assert.notOk(value._destroyed, `Attached cache '${key}' is not destroyed.`);
assert.ok(value._tiles.includes(tile), `Attached cache '${key}' reference is bidirectional.`);
}
assert.notOk(tile.getCache(tile._wcKey), "Tile cache working key is unset");
}
done();
});
});
QUnit.test(type + ' drawer: basic scenario with priorities + events addition.', function(assert) {
whiteViewport();
const done = assert.async();
// FNA gets applied last since it has low priority
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();
viewer.addHandler('open', async () => {
await viewer.waitForFinishedJobsForTest();
let data = await readTileData();
assert.equal(data.data[0], 0);
assert.equal(data.data[1], 0);
assert.equal(data.data[2], 255);
assert.equal(data.data[3], 255);
// Test swap
viewer.addHandler('tile-loaded', fnB);
viewer.addHandler('tile-invalidated', fnB);
await viewer.requestInvalidate();
data = await readTileData();
// suddenly B is applied since it was added with same priority but later
assert.equal(data.data[0], 255);
assert.equal(data.data[1], 0);
assert.equal(data.data[2], 0);
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
data = await readTileData();
assert.equal(data.data[0], 255);
assert.equal(data.data[1], 0);
assert.equal(data.data[2], 0);
assert.equal(data.data[3], 255);
// Thorough testing of the cache state
for (let tile of viewer.tileCache._tilesLoaded) {
const caches = Object.entries(tile._caches);
assert.equal(caches.length, 2, `Tile ${getTileDescription(tile)} has only two caches - main & original`);
for (let [key, value] of caches) {
assert.ok(value.loaded, `Attached cache '${key}' is ready.`);
assert.notOk(value._destroyed, `Attached cache '${key}' is not destroyed.`);
assert.ok(value._tiles.includes(tile), `Attached cache '${key}' reference is bidirectional.`);
}
assert.notOk(tile.getCache(tile._wcKey), "Tile cache working key is unset");
}
done();
});
});
QUnit.test(type + ' drawer: one calls tile restore.', function(assert) {
whiteViewport();
const done = assert.async();
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();
viewer.addHandler('open', async () => {
await viewer.waitForFinishedJobsForTest();
let data = await readTileData();
assert.equal(data.data[0], 0);
assert.equal(data.data[1], 255);
assert.equal(data.data[2], 0);
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();
assert.equal(data.data[0], 255);
assert.equal(data.data[1], 255);
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();
//Erased!
assert.equal(data.data[0], 255);
assert.equal(data.data[1], 255);
assert.equal(data.data[2], 255);
assert.equal(data.data[3], 255);
// Thorough testing of the cache state
for (let tile of viewer.tileCache._tilesLoaded) {
const caches = Object.entries(tile._caches);
assert.equal(caches.length, 1, `Tile ${getTileDescription(tile)} has only single, original cache`);
for (let [key, value] of caches) {
assert.ok(value.loaded, `Attached cache '${key}' is ready.`);
assert.notOk(value._destroyed, `Attached cache '${key}' is not destroyed.`);
assert.ok(value._tiles.includes(tile), `Attached cache '${key}' reference is bidirectional.`);
}
assert.notOk(tile.getCache(tile._wcKey), "Tile cache working key is unset");
}
done();
});
});
}
}());

View File

@ -2,8 +2,7 @@
(function() {
var viewer;
const drawerTypes = ['webgl','canvas','html'];
drawerTypes.forEach(runDrawerTests);
OpenSeadragon.getBuiltInDrawersForTest().forEach(runDrawerTests);
function runDrawerTests(drawerType){

View File

@ -113,7 +113,6 @@
});
// ----------
// TODO: this used to be async
QUnit.test('basics', function(assert) {
const done = assert.async();
const fakeViewer = MockSeadragon.getViewer(

View File

@ -66,6 +66,7 @@
<script src="/test/modules/ajax-post-data.js"></script>
<script src="/test/modules/imageloader.js"></script>
<script src="/test/modules/tilesource-dynamic-url.js"></script>
<script src="/test/modules/data-manipulation.js"></script>
<!--The navigator tests are the slowest (for now; hopefully they can be sped up)
so we put them last. -->
<script src="/test/modules/navigator.js"></script>