Add tests for zombie and data type conversion, ensure destructors are called.

Fix bugs (zombie was disabled on item replace, fix zombie cache system by separating to its own cache array). Fix CacheRecord destructor & dijkstra. Deduce cache only from originalCacheKey. Force explicit type declaration with types on users.
This commit is contained in:
Aiosa 2023-11-18 20:16:35 +01:00
parent c3ab9a08e7
commit 219049976c
10 changed files with 641 additions and 234 deletions

View File

@ -43,6 +43,12 @@ class WeightedGraph {
this.adjacencyList = {};
this.vertices = {};
}
/**
* Add vertex to graph
* @param vertex unique vertex ID
* @return {boolean} true if inserted, false if exists (no-op)
*/
addVertex(vertex) {
if (!this.vertices[vertex]) {
this.vertices[vertex] = new $.PriorityQueue.Node(0, vertex);
@ -51,8 +57,28 @@ class WeightedGraph {
}
return false;
}
/**
* Add edge to graph
* @param vertex1 id, must exist by calling addVertex()
* @param vertex2 id, must exist by calling addVertex()
* @param weight
* @param transform function that transforms on path vertex1 -> vertex2
* @return {boolean} true if new edge, false if replaced existing
*/
addEdge(vertex1, vertex2, weight, transform) {
this.adjacencyList[vertex1].push({ target: this.vertices[vertex2], origin: this.vertices[vertex1], weight, transform });
if (weight < 0) {
$.console.error("WeightedGraph: negative weights will make for invalid shortest path computation!");
}
const outgoingPaths = this.adjacencyList[vertex1],
replacedEdgeIndex = outgoingPaths.findIndex(edge => edge.target === this.vertices[vertex2]),
newEdge = { target: this.vertices[vertex2], origin: this.vertices[vertex1], weight, transform };
if (replacedEdgeIndex < 0) {
this.adjacencyList[vertex1].push(newEdge);
return true;
}
this.adjacencyList[vertex1][replacedEdgeIndex] = newEdge;
return false;
}
/**
@ -97,7 +123,7 @@ class WeightedGraph {
}
}
if (!smallestNode || !smallestNode._previous) {
if (!smallestNode || !smallestNode._previous || smallestNode.value !== finish) {
return undefined; //no path
}
@ -158,11 +184,11 @@ $.DataTypeConvertor = class {
// Teaching OpenSeadragon built-in conversions:
this.learn("canvas", "rasterUrl", (canvas) => canvas.toDataURL(), 1, 1);
this.learn("image", "rasterUrl", (image) => image.url);
this.learn("canvas", "context2d", (canvas) => canvas.getContext("2d"));
this.learn("context2d", "canvas", (context2D) => context2D.canvas);
this.learn("image", "canvas", (image) => {
this.learn("canvas", "url", canvas => canvas.toDataURL(), 1, 1);
this.learn("image", "url", image => image.url);
this.learn("canvas", "context2d", canvas => canvas.getContext("2d"));
this.learn("context2d", "canvas", context2D => context2D.canvas);
this.learn("image", "canvas", image => {
const canvas = document.createElement( 'canvas' );
canvas.width = image.width;
canvas.height = image.height;
@ -170,7 +196,7 @@ $.DataTypeConvertor = class {
context.drawImage( image, 0, 0 );
return canvas;
}, 1, 1);
this.learn("rasterUrl", "image", (url) => {
this.learn("url", "image", url => {
return new $.Promise((resolve, reject) => {
const img = new Image();
img.onerror = img.onabort = reject;
@ -181,17 +207,15 @@ $.DataTypeConvertor = class {
}
/**
* FIXME: types are sensitive thing. Same data type might have different data semantics.
* - 'string' can be anything, for images, dataUrl or some URI, or incompatible stuff: vector data (JSON)
* - using $.type makes explicit requirements on its extensibility, and makes mess in naming
* - most types are [object X]
* - selected types are 'nice' -> string, canvas...
* - hard to debug
*
* Unique identifier (unlike toString.call(x)) to be guessed
* from the data value
* from the data value. This type guess is more strict than
* OpenSeadragon.type() implementation, but for most type recognition
* this test relies on the output of OpenSeadragon.type().
*
* @function uniqueType
* Note: although we try to implement the type guessing, do
* not rely on this functionality! Prefer explicit type declaration.
*
* @function guessType
* @param x object to get unique identifier for
* - can be array, in that case, alphabetically-ordered list of inner unique types
* is returned (null, undefined are ignored)
@ -368,6 +392,23 @@ $.DataTypeConvertor = class {
}
return bestConvertorPath ? bestConvertorPath.path : undefined;
}
/**
* Return a list of known conversion types
* @return {string[]}
*/
getKnownTypes() {
return Object.keys(this.graph.vertices);
}
/**
* Check whether given type is known to the convertor
* @param {string} type type to test
* @return {boolean}
*/
existsType(type) {
return !!this.graph.vertices[type];
}
};
/**
@ -376,7 +417,7 @@ $.DataTypeConvertor = class {
* Built-in conversions include types:
* - context2d canvas 2d context
* - image HTMLImage element
* - rasterUrl url string carrying or pointing to 2D raster data
* - url url string carrying or pointing to 2D raster data
* - canvas HTMLCanvas element
*
* @type OpenSeadragon.DataTypeConvertor

View File

@ -95,7 +95,7 @@ $.ImageJob.prototype = {
var selfAbort = this.abort;
this.jobId = window.setTimeout(function () {
self.finish(null, null, "Image load exceeded timeout (" + self.timeout + " ms)");
self.fail("Image load exceeded timeout (" + self.timeout + " ms)", null);
}, this.timeout);
this.abort = function() {

View File

@ -149,6 +149,8 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja
* that holds the cache original data (it was loaded with). In case you
* change the tile data, the tile original data should be left with the cache
* 'originalCacheKey' and the new, modified data should be stored in cache 'cacheKey'.
* This key is used in cache resolution: in case new tile data is requested, if
* this cache key exists in the cache it is loaded.
* @member {String} originalCacheKey
* @memberof OpenSeadragon.Tile#
*/
@ -444,6 +446,7 @@ $.Tile.prototype = {
/**
* Get the default data for this tile
* @param {?string} [type=undefined] data type to require
* @return {*|undefined} data in the desired type, or undefined if a conversion is ongoing
*/
getData(type = undefined) {
const cache = this.getCache(this.cacheKey);
@ -494,7 +497,12 @@ $.Tile.prototype = {
* @param [_cutoff=0] private
*/
setCache: function(key, data, type = undefined, _safely = true, _cutoff = 0) {
type = type || $.convertor.guessType(data);
if (!type && this.tiledImage && !this.tiledImage.__typeWarningReported) {
$.console.warn(this, "[Tile.setCache] called without type specification. " +
"Automated deduction is potentially unsafe: prefer specification of data type explicitly.");
this.tiledImage.__typeWarningReported = true;
type = $.convertor.guessType(data);
}
if (_safely && key === this.cacheKey) {
//todo later, we could have drawers register their supported rendering type

View File

@ -62,17 +62,23 @@ $.CacheRecord = class {
}
destroy() {
this._tiles = null;
this._data = null;
this._type = null;
this.loaded = false;
//make sure this gets destroyed even if loaded=false
if (this.loaded) {
$.convertor.destroy(this._type, this._data);
this._tiles = null;
this._data = null;
this._type = null;
this._promise = $.Promise.resolve();
} else {
this._promise.then(x => $.convertor.destroy(this._type, x));
this._promise.then(x => {
$.convertor.destroy(this._type, x);
this._tiles = null;
this._data = null;
this._type = null;
this._promise = $.Promise.resolve();
});
}
this._promise = $.Promise.resolve();
this.loaded = false;
}
get data() {
@ -119,7 +125,9 @@ $.CacheRecord = class {
this._data = data;
this.loaded = true;
} else if (this._type !== type) {
$.console.warn("[CacheRecord.addTile] Tile %s was added to an existing cache, but the tile is supposed to carry incompatible data type %s!", tile, type);
//pass: the tile data type will silently change
// as it inherits this cache
// todo do not call events?
}
this._tiles.push(tile);
@ -164,29 +172,28 @@ $.CacheRecord = class {
stepCount = conversionPath.length,
_this = this,
convert = (x, i) => {
if (i >= stepCount) {
_this._data = x;
_this.loaded = true;
return $.Promise.resolve(x);
}
let edge = conversionPath[i];
return $.Promise.resolve(edge.transform(x)).then(
y => {
if (!y) {
$.console.error(`[OpenSeadragon.convertor.convert] data mid result falsey value (while converting using %s)`, edge);
//try to recover using original data, but it returns inconsistent type (the log be hopefully enough)
_this._data = from;
_this._type = from;
_this.loaded = true;
return originalData;
}
//node.value holds the type string
convertor.destroy(edge.origin.value, x);
return convert(y, i + 1);
if (i >= stepCount) {
_this._data = x;
_this.loaded = true;
return $.Promise.resolve(x);
}
);
};
let edge = conversionPath[i];
return $.Promise.resolve(edge.transform(x)).then(
y => {
if (!y) {
$.console.error(`[OpenSeadragon.convertor.convert] data mid result falsey value (while converting using %s)`, edge);
//try to recover using original data, but it returns inconsistent type (the log be hopefully enough)
_this._data = from;
_this._type = from;
_this.loaded = true;
return originalData;
}
//node.value holds the type string
convertor.destroy(edge.origin.value, x);
return convert(y, i + 1);
}
);
};
this.loaded = false;
this._data = undefined;
@ -195,124 +202,6 @@ $.CacheRecord = class {
}
};
//FIXME: really implement or throw away? new parameter would allow users to
// use this implementation instead of the above to allow caching for old data
// (for example in the default use, the data is downloaded as an image, and
// converted to a canvas -> the image record gets thrown away)
//
//FIXME: Note that this can be also achieved somewhat by caching the midresults
// as a single cache object instead. Also, there is the problem of lifecycle-oriented
// data types such as WebGL textures we want to unload manually: this looks like
// we really want to cache midresuls and have their custom destructors
// $.MemoryCacheRecord = class extends $.CacheRecord {
// constructor(memorySize) {
// super();
// this.length = memorySize;
// this.index = 0;
// this.content = [];
// this.types = [];
// this.defaultType = "image";
// }
//
// // overrides:
//
// destroy() {
// super.destroy();
// this.types = null;
// this.content = null;
// this.types = null;
// this.defaultType = null;
// }
//
// getData(type = this.defaultType) {
// let item = this.add(type, undefined);
// if (item === undefined) {
// //no such type available, get if possible
// //todo: possible unomptimal use, we could cache costs and re-use known paths, though it adds overhead...
// item = $.convertor.convert(this.current(), this.currentType(), type);
// this.add(type, item);
// }
// return item;
// }
//
// /**
// * @deprecated
// */
// get data() {
// $.console.warn("[MemoryCacheRecord.data] is deprecated property. Use getData(...) instead!");
// return this.current();
// }
//
// /**
// * @deprecated
// * @param value
// */
// set data(value) {
// //FIXME: addTile bit bad name, related to the issue mentioned elsewhere
// $.console.warn("[MemoryCacheRecord.data] is deprecated property. Use addTile(...) instead!");
// this.defaultType = $.convertor.guessType(value);
// this.add(this.defaultType, value);
// }
//
// addTile(tile, data, type) {
// $.console.assert(tile, '[CacheRecord.addTile] tile is required');
//
// //allow overriding the cache - existing tile or different type
// if (this._tiles.includes(tile)) {
// this.removeTile(tile);
// } else if (!this.defaultType !== type) {
// this.defaultType = type;
// this.add(type, data);
// }
//
// this._tiles.push(tile);
// }
//
// // extends:
//
// add(type, item) {
// const index = this.hasIndex(type);
// if (index > -1) {
// //no index change, swap (optimally, move all by one - too expensive...)
// item = this.content[index];
// this.content[index] = this.content[this.index];
// } else {
// this.index = (this.index + 1) % this.length;
// }
// this.content[this.index] = item;
// this.types[this.index] = type;
// return item;
// }
//
// has(type) {
// for (let i = 0; i < this.types.length; i++) {
// const t = this.types[i];
// if (t === type) {
// return this.content[i];
// }
// }
// return undefined;
// }
//
// hasIndex(type) {
// for (let i = 0; i < this.types.length; i++) {
// const t = this.types[i];
// if (t === type) {
// return i;
// }
// }
// return -1;
// }
//
// current() {
// return this.content[this.index];
// }
//
// currentType() {
// return this.types[this.index];
// }
// };
/**
* @class TileCache
* @memberof OpenSeadragon
@ -328,6 +217,8 @@ $.TileCache = class {
this._maxCacheItemCount = options.maxImageCacheCount || $.DEFAULT_SETTINGS.maxImageCacheCount;
this._tilesLoaded = [];
this._zombiesLoaded = [];
this._zombiesLoadedCount = 0;
this._cachesLoaded = [];
this._cachesLoadedCount = 0;
}
@ -368,7 +259,7 @@ $.TileCache = class {
insertionIndex = this._tilesLoaded.length,
cacheKey = options.cacheKey || options.tile.cacheKey;
let cacheRecord = this._cachesLoaded[options.tile.cacheKey];
let cacheRecord = this._cachesLoaded[cacheKey] || this._zombiesLoaded[cacheKey];
if (!cacheRecord) {
if (!options.data) {
@ -378,14 +269,12 @@ $.TileCache = class {
}
$.console.assert( options.data, "[TileCache.cacheTile] options.data is required to create an CacheRecord" );
cacheRecord = this._cachesLoaded[options.tile.cacheKey] = new $.CacheRecord();
cacheRecord = this._cachesLoaded[cacheKey] = new $.CacheRecord();
this._cachesLoadedCount++;
} else if (cacheRecord.__zombie__) {
delete cacheRecord.__zombie__;
//revive cache, replace from _tilesLoaded so it won't get unloaded
this._tilesLoaded.splice( cacheRecord.__index__, 1 );
delete cacheRecord.__index__;
insertionIndex--;
} else if (!cacheRecord.getTileCount()) {
//revive zombie
delete this._zombiesLoaded[cacheKey];
this._zombiesLoadedCount--;
}
if (!options.dataType) {
@ -398,52 +287,48 @@ $.TileCache = class {
// Note that just because we're unloading a tile doesn't necessarily mean
// we're unloading its cache records. With repeated calls it should sort itself out, though.
if ( this._cachesLoadedCount > this._maxCacheItemCount ) {
let worstTile = null;
let worstTileIndex = -1;
let prevTile, worstTime, worstLevel, prevTime, prevLevel;
for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) {
prevTile = this._tilesLoaded[ i ];
//todo try different approach? the only ugly part, keep tilesLoaded array empty of unloaded tiles
if (!prevTile.loaded) {
//iterates from the array end, safe to remove
this._tilesLoaded.splice( i, 1 );
continue;
}
if ( prevTile.__zombie__ !== undefined ) {
//remove without hesitation, CacheRecord instance
worstTile = prevTile.__zombie__;
worstTileIndex = i;
if ( this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount ) {
//prefer zombie deletion, faster, better
if (this._zombiesLoadedCount > 0) {
for (let zombie in this._zombiesLoaded) {
this._zombiesLoaded[zombie].destroy();
delete this._zombiesLoaded[zombie];
this._zombiesLoadedCount--;
break;
}
} else {
let worstTile = null;
let worstTileIndex = -1;
let prevTile, worstTime, worstLevel, prevTime, prevLevel;
if ( prevTile.level <= cutoff || prevTile.beingDrawn ) {
continue;
} else if ( !worstTile ) {
worstTile = prevTile;
worstTileIndex = i;
continue;
for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) {
prevTile = this._tilesLoaded[ i ];
if ( prevTile.level <= cutoff || prevTile.beingDrawn ) {
continue;
} else if ( !worstTile ) {
worstTile = prevTile;
worstTileIndex = i;
continue;
}
prevTime = prevTile.lastTouchTime;
worstTime = worstTile.lastTouchTime;
prevLevel = prevTile.level;
worstLevel = worstTile.level;
if ( prevTime < worstTime ||
( prevTime === worstTime && prevLevel > worstLevel )) {
worstTile = prevTile;
worstTileIndex = i;
}
}
prevTime = prevTile.lastTouchTime;
worstTime = worstTile.lastTouchTime;
prevLevel = prevTile.level;
worstLevel = worstTile.level;
if ( prevTime < worstTime ||
( prevTime === worstTime && prevLevel > worstLevel )) {
worstTile = prevTile;
worstTileIndex = i;
if ( worstTile && worstTileIndex >= 0 ) {
this._unloadTile(worstTile, true);
insertionIndex = worstTileIndex;
}
}
if ( worstTile && worstTileIndex >= 0 ) {
this._unloadTile(worstTile, true);
insertionIndex = worstTileIndex;
}
}
this._tilesLoaded[ insertionIndex ] = options.tile;
@ -456,17 +341,28 @@ $.TileCache = class {
clearTilesFor( tiledImage ) {
$.console.assert(tiledImage, '[TileCache.clearTilesFor] tiledImage is required');
let tile;
let cacheOverflows = this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount;
if (tiledImage._zombieCache && cacheOverflows && this._zombiesLoadedCount > 0) {
//prefer newer zombies
for (let zombie in this._zombiesLoaded) {
this._zombiesLoaded[zombie].destroy();
delete this._zombiesLoaded[zombie];
}
this._zombiesLoadedCount = 0;
cacheOverflows = this._cachesLoadedCount > this._maxCacheItemCount;
}
for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) {
tile = this._tilesLoaded[ i ];
//todo try different approach? the only ugly part, keep tilesLoaded array empty of unloaded tiles
//todo might be errorprone: tile.loading true--> problem! maybe set some other flag by
if (!tile.loaded) {
//iterates from the array end, safe to remove
this._tilesLoaded.splice( i, 1 );
i--;
} else if ( tile.tiledImage === tiledImage ) {
this._unloadTile(tile, !tiledImage._zombieCache ||
this._cachesLoadedCount > this._maxCacheItemCount, i);
//todo tile loading, if abort... we cloud notify the cache, maybe it works (cache destroy will wait for conversion...)
this._unloadTile(tile, !tiledImage._zombieCache || cacheOverflows, i);
}
}
}
@ -496,18 +392,14 @@ $.TileCache = class {
cacheRecord.destroy();
delete this._cachesLoaded[tile.cacheKey];
this._cachesLoadedCount--;
//delete also the tile record
if (deleteAtIndex !== undefined) {
this._tilesLoaded.splice( deleteAtIndex, 1 );
}
} else if (deleteAtIndex !== undefined) {
// #2 Tile is a zombie. Do not delete record, reuse.
// a bit dirty but performant... -> we can remove later, or revive
// we can do this, in array the tile is once for each its cache object
this._tilesLoaded[ deleteAtIndex ] = cacheRecord;
cacheRecord.__zombie__ = tile;
cacheRecord.__index__ = deleteAtIndex;
this._zombiesLoaded[ tile.cacheKey ] = cacheRecord;
this._zombiesLoadedCount++;
}
//delete also the tile record
if (deleteAtIndex !== undefined) {
this._tilesLoaded.splice( deleteAtIndex, 1 );
}
} else if (deleteAtIndex !== undefined) {
// #3 Cache stays. Tile record needs to be removed anyway, since the tile is removed.
@ -528,10 +420,12 @@ $.TileCache = class {
* @type {object}
* @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the unloaded tile.
* @property {OpenSeadragon.Tile} tile - The tile which has been unloaded.
* @property {boolean} destroyed - False if the tile data was kept in the system.
*/
tiledImage.viewer.raiseEvent("tile-unloaded", {
tile: tile,
tiledImage: tiledImage
tiledImage: tiledImage,
destroyed: destroy
});
}
};

View File

@ -1536,12 +1536,16 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
tile.cacheKey = "";
tile.originalCacheKey = "";
}
const similarCacheRecord =
this._tileCache.getCacheRecord(tile.originalCacheKey) ||
this._tileCache.getCacheRecord(tile.cacheKey);
//do not use tile.cacheKey: that cache might be different from what we really want
// since this request could come from different tiled image and might not want
// to use the modified data
const similarCacheRecord = this._tileCache.getCacheRecord(tile.originalCacheKey);
if (similarCacheRecord) {
const cutoff = this.source.getClosestLevel();
tile.loading = true;
tile.loaded = false;
if (similarCacheRecord.loaded) {
this._setTileLoaded(tile, similarCacheRecord.data, cutoff, null, similarCacheRecord.type);
} else {

View File

@ -786,7 +786,7 @@ $.TileSource.prototype = {
return;
}
image.onload = image.onerror = image.onabort = null;
context.finish(image, dataStore.request); //dataType="image" recognized automatically
context.finish(image, dataStore.request, "image");
};
image.onload = function () {
finalize();
@ -900,7 +900,7 @@ $.TileSource.prototype = {
* Raw data getter, should return anything that is compatible with the system, or undefined
* if the system can handle it.
* @param {OpenSeadragon.CacheRecord} cacheObject context cache object
* @returns {Promise<?>} cache data
* @returns {OpenSeadragon.Promise<?>} cache data
* @deprecated
*/
getTileCacheData: function(cacheObject) {

View File

@ -1579,7 +1579,6 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype,
if (newIndex !== -1) {
queueItem.options.index = newIndex;
}
queueItem.options.replaceItem.allowZombieCache(queueItem.options.zombieCache || false);
_this.world.removeItem(queueItem.options.replaceItem);
}

View File

@ -1,13 +1,41 @@
/* global QUnit, testLog */
(function() {
let viewer;
//we override jobs: remember original function
const originalJob = OpenSeadragon.ImageLoader.prototype.addJob;
//event awaiting
function waitFor(predicate) {
const time = setInterval(() => {
if (predicate()) {
clearInterval(time);
}
}, 20);
}
// ----------
QUnit.module('TileCache', {
beforeEach: function () {
$('<div id="example"></div>').appendTo("#qunit-fixture");
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
});
OpenSeadragon.ImageLoader.prototype.addJob = originalJob;
},
afterEach: function () {
if (viewer && viewer.close) {
viewer.close();
}
viewer = null;
}
});
@ -146,4 +174,159 @@
done();
});
QUnit.test('Zombie Cache', function(test) {
const done = test.async();
//test jobs by coverage: fail if
let jobCounter = 0, coverage = undefined;
OpenSeadragon.ImageLoader.prototype.addJob = function (options) {
jobCounter++;
if (coverage) {
//old coverage of previous tiled image: if loaded, fail --> should be in cache
const coverageItem = coverage[options.tile.level][options.tile.x][options.tile.y];
test.ok(!coverageItem, "Attempt to add job for tile that is not in cache OK if previously not loaded.");
}
return originalJob.call(this, options);
};
let tilesFinished = 0;
const tileCounter = function (event) {tilesFinished++;}
const openHandler = function(event) {
event.item.allowZombieCache(true);
viewer.world.removeHandler('add-item', openHandler);
test.ok(jobCounter === 0, 'Initial state, no images loaded');
waitFor(() => {
if (tilesFinished === jobCounter && event.item._fullyLoaded) {
coverage = $.extend(true, {}, event.item.coverage);
viewer.world.removeAll();
return true;
}
return false;
});
};
let jobsAfterRemoval = 0;
const removalHandler = function (event) {
viewer.world.removeHandler('remove-item', removalHandler);
test.ok(jobCounter > 0, 'Tiled image removed after 100 ms, should load some images.');
jobsAfterRemoval = jobCounter;
viewer.world.addHandler('add-item', reopenHandler);
viewer.addTiledImage({
tileSource: '/test/data/testpattern.dzi'
});
}
const reopenHandler = function (event) {
event.item.allowZombieCache(true);
viewer.removeHandler('add-item', reopenHandler);
test.equal(jobCounter, jobsAfterRemoval, 'Reopening image does not fetch any tiles imemdiatelly.');
waitFor(() => {
if (event.item._fullyLoaded) {
viewer.removeHandler('tile-unloaded', unloadTileHandler);
viewer.removeHandler('tile-loaded', tileCounter);
//console test needs here explicit removal to finish correctly
OpenSeadragon.ImageLoader.prototype.addJob = originalJob;
done();
return true;
}
return false;
});
};
const unloadTileHandler = function (event) {
test.equal(event.destroyed, false, "Tile unload event should not delete with zombies!");
}
viewer.world.addHandler('add-item', openHandler);
viewer.world.addHandler('remove-item', removalHandler);
viewer.addHandler('tile-unloaded', unloadTileHandler);
viewer.addHandler('tile-loaded', tileCounter);
viewer.open('/test/data/testpattern.dzi');
});
QUnit.test('Zombie Cache Replace Item', function(test) {
const done = test.async();
//test jobs by coverage: fail if
let jobCounter = 0, coverage = undefined;
OpenSeadragon.ImageLoader.prototype.addJob = function (options) {
jobCounter++;
if (coverage) {
//old coverage of previous tiled image: if loaded, fail --> should be in cache
const coverageItem = coverage[options.tile.level][options.tile.x][options.tile.y];
if (!coverageItem) {
console.warn(coverage, coverage[options.tile.level][options.tile.x], options.tile);
}
test.ok(!coverageItem, "Attempt to add job for tile data that was previously loaded.");
}
return originalJob.call(this, options);
};
let tilesFinished = 0;
const tileCounter = function (event) {tilesFinished++;}
const openHandler = function(event) {
event.item.allowZombieCache(true);
viewer.world.removeHandler('add-item', openHandler);
viewer.world.addHandler('add-item', reopenHandler);
const oldCacheSize = event.item._tileCache._cachesLoadedCount +
event.item._tileCache._zombiesLoadedCount;
waitFor(() => {
if (tilesFinished === jobCounter && event.item._fullyLoaded) {
coverage = $.extend(true, {}, event.item.coverage);
viewer.addTiledImage({
tileSource: '/test/data/testpattern.dzi',
index: 0,
replace: true,
success: e => {
test.equal(oldCacheSize, e.item._tileCache._cachesLoadedCount +
e.item._tileCache._zombiesLoadedCount,
"Image replace should erase no cache with zombies.");
}
});
return true;
}
return false;
});
};
const reopenHandler = function (event) {
event.item.allowZombieCache(true);
viewer.removeHandler('add-item', reopenHandler);
waitFor(() => {
if (event.item._fullyLoaded) {
viewer.removeHandler('tile-unloaded', unloadTileHandler);
viewer.removeHandler('tile-loaded', tileCounter);
//console test needs here explicit removal to finish correctly
OpenSeadragon.ImageLoader.prototype.addJob = originalJob;
done();
return true;
}
return false;
});
};
const unloadTileHandler = function (event) {
test.equal(event.destroyed, false, "Tile unload event should not delete with zombies!");
}
viewer.world.addHandler('add-item', openHandler);
viewer.addHandler('tile-unloaded', unloadTileHandler);
viewer.addHandler('tile-loaded', tileCounter);
viewer.open('/test/data/testpattern.dzi');
});
})();

View File

@ -0,0 +1,277 @@
/* global QUnit, $, Util, testLog */
(function() {
const Convertor = OpenSeadragon.convertor;
let viewer;
//we override jobs: remember original function
const originalJob = OpenSeadragon.ImageLoader.prototype.addJob;
//event awaiting
function waitFor(predicate) {
const time = setInterval(() => {
if (predicate()) {
clearInterval(time);
}
}, 20);
}
//hijack conversion paths
//count jobs: how many items we process?
let jobCounter = 0;
OpenSeadragon.ImageLoader.prototype.addJob = function (options) {
jobCounter++;
return originalJob.call(this, options);
};
// Replace conversion with our own system and test: __TEST__ prefix must be used, otherwise
// other tests will interfere
let imageToCanvas = 0, srcToImage = 0, context2DtoImage = 0, canvasToContext2D = 0, imageToUrl = 0,
canvasToUrl = 0;
//set all same costs to get easy testing, know which path will be taken
Convertor.learn("__TEST__canvas", "__TEST__url", canvas => {
canvasToUrl++;
return canvas.toDataURL();
}, 1, 1);
Convertor.learn("__TEST__image", "__TEST__url", image => {
imageToUrl++;
return image.url;
}, 1, 1);
Convertor.learn("__TEST__canvas", "__TEST__context2d", canvas => {
canvasToContext2D++;
return canvas.getContext("2d");
}, 1, 1);
Convertor.learn("__TEST__context2d", "__TEST__canvas", context2D => {
context2DtoImage++;
return context2D.canvas;
}, 1, 1);
Convertor.learn("__TEST__image", "__TEST__canvas", image => {
imageToCanvas++;
const canvas = document.createElement( 'canvas' );
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
context.drawImage( image, 0, 0 );
return canvas;
}, 1, 1);
Convertor.learn("__TEST__url", "__TEST__image", url => {
return new Promise((resolve, reject) => {
srcToImage++;
const img = new Image();
img.onerror = img.onabort = reject;
img.onload = () => resolve(img);
img.src = url;
});
}, 1, 1);
let canvasDestroy = 0, imageDestroy = 0, contex2DDestroy = 0, urlDestroy = 0;
//also learn destructors
Convertor.learnDestroy("__TEST__canvas", () => {
canvasDestroy++;
});
Convertor.learnDestroy("__TEST__image", () => {
imageDestroy++;
});
Convertor.learnDestroy("__TEST__context2d", () => {
contex2DDestroy++;
});
Convertor.learnDestroy("__TEST__url", () => {
urlDestroy++;
});
QUnit.module('TypeConversion', {
beforeEach: function () {
$('<div id="example"></div>').appendTo("#qunit-fixture");
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
});
OpenSeadragon.ImageLoader.prototype.addJob = originalJob;
},
afterEach: function () {
if (viewer && viewer.close) {
viewer.close();
}
viewer = null;
imageToCanvas = 0; srcToImage = 0; context2DtoImage = 0;
canvasToContext2D = 0; imageToUrl = 0; canvasToUrl = 0;
canvasDestroy = 0; imageDestroy = 0; contex2DDestroy = 0; urlDestroy = 0;
}
});
QUnit.test('Conversion path deduction', function (test) {
const done = test.async();
test.ok(Convertor.getConversionPath("__TEST__url", "__TEST__image"),
"Type conversion ok between TEST types.");
test.ok(Convertor.getConversionPath("canvas", "context2d"),
"Type conversion ok between real types.");
test.equal(Convertor.getConversionPath("url", "__TEST__image"), undefined,
"Type conversion not possible between TEST and real types.");
test.equal(Convertor.getConversionPath("__TEST__canvas", "context2d"), undefined,
"Type conversion not possible between TEST and real types.");
done();
});
// ----------
QUnit.test('Manual Data Convertors: testing conversion & destruction', function (test) {
const done = test.async();
//load image object: url -> image
Convertor.convert("/test/data/A.png", "__TEST__url", "__TEST__image").then(i => {
test.equal(OpenSeadragon.type(i), "image", "Got image object after conversion.");
test.equal(srcToImage, 1, "Conversion happened.");
test.equal(urlDestroy, 1, "Url destructor called.");
test.equal(imageDestroy, 0, "Image destructor not called.");
return Convertor.convert(i, "__TEST__image", "__TEST__canvas");
}).then(c => { //path image -> canvas
test.equal(OpenSeadragon.type(c), "canvas", "Got canvas object after conversion.");
test.equal(srcToImage, 1, "Conversion ulr->image did not happen.");
test.equal(imageToCanvas, 1, "Conversion image->canvas happened.");
test.equal(urlDestroy, 1, "Url destructor not called.");
test.equal(imageDestroy, 1, "Image destructor called.");
return Convertor.convert(c, "__TEST__canvas", "__TEST__image");
}).then(i => { //path canvas, image: canvas -> url -> image
test.equal(OpenSeadragon.type(i), "image", "Got image object after conversion.");
test.equal(srcToImage, 2, "Conversion ulr->image happened.");
test.equal(imageToCanvas, 1, "Conversion image->canvas did not happened.");
test.equal(context2DtoImage, 0, "Conversion c2d->image did not happened.");
test.equal(canvasToContext2D, 0, "Conversion canvas->c2d did not happened.");
test.equal(canvasToUrl, 1, "Conversion canvas->url happened.");
test.equal(imageToUrl, 0, "Conversion image->url did not happened.");
test.equal(urlDestroy, 2, "Url destructor called.");
test.equal(imageDestroy, 1, "Image destructor not called.");
test.equal(canvasDestroy, 1, "Canvas destructor called.");
test.equal(contex2DDestroy, 0, "Image destructor not called.");
done();
});
});
QUnit.test('Data Convertors via Cache object: testing conversion & destruction', function (test) {
const done = test.async();
const dummyRect = new OpenSeadragon.Rect(0, 0, 0, 0, 0);
const dummyTile = new OpenSeadragon.Tile(0, 0, 0, dummyRect, true, "",
undefined, true, null, dummyRect, "", "key");
const cache = new OpenSeadragon.CacheRecord();
cache.addTile(dummyTile, "/test/data/A.png", "__TEST__url");
//load image object: url -> image
cache.getData("__TEST__image").then(_ => {
test.equal(OpenSeadragon.type(cache.data), "image", "Got image object after conversion.");
test.equal(srcToImage, 1, "Conversion happened.");
test.equal(urlDestroy, 1, "Url destructor called.");
test.equal(imageDestroy, 0, "Image destructor not called.");
return cache.getData("__TEST__canvas");
}).then(_ => { //path image -> canvas
test.equal(OpenSeadragon.type(cache.data), "canvas", "Got canvas object after conversion.");
test.equal(srcToImage, 1, "Conversion ulr->image did not happen.");
test.equal(imageToCanvas, 1, "Conversion image->canvas happened.");
test.equal(urlDestroy, 1, "Url destructor not called.");
test.equal(imageDestroy, 1, "Image destructor called.");
return cache.getData("__TEST__image");
}).then(_ => { //path canvas, image: canvas -> url -> image
test.equal(OpenSeadragon.type(cache.data), "image", "Got image object after conversion.");
test.equal(srcToImage, 2, "Conversion ulr->image happened.");
test.equal(imageToCanvas, 1, "Conversion image->canvas did not happened.");
test.equal(context2DtoImage, 0, "Conversion c2d->image did not happened.");
test.equal(canvasToContext2D, 0, "Conversion canvas->c2d did not happened.");
test.equal(canvasToUrl, 1, "Conversion canvas->url happened.");
test.equal(imageToUrl, 0, "Conversion image->url did not happened.");
test.equal(urlDestroy, 2, "Url destructor called.");
test.equal(imageDestroy, 1, "Image destructor not called.");
test.equal(canvasDestroy, 1, "Canvas destructor called.");
test.equal(contex2DDestroy, 0, "Image destructor not called.");
}).then(_ => {
cache.destroy();
test.equal(urlDestroy, 2, "Url destructor not called.");
test.equal(imageDestroy, 2, "Image destructor called.");
test.equal(canvasDestroy, 1, "Canvas destructor not called.");
test.equal(contex2DDestroy, 0, "Image destructor not called.");
done();
});
});
QUnit.test('Deletion cache while being in the conversion process', function (test) {
const done = test.async();
let conversionHappened = false;
Convertor.learn("__TEST__url", "__TEST__longConversionProcessForTesting", _ => {
return new Promise((resolve, reject) => {
setTimeout(() => {
conversionHappened = true;
resolve("some interesting data");
}, 20);
});
}, 1, 1);
let destructionHappened = false;
Convertor.learnDestroy("__TEST__longConversionProcessForTesting", _ => {
destructionHappened = true;
});
const dummyRect = new OpenSeadragon.Rect(0, 0, 0, 0, 0);
const dummyTile = new OpenSeadragon.Tile(0, 0, 0, dummyRect, true, "",
undefined, true, null, dummyRect, "", "key");
const cache = new OpenSeadragon.CacheRecord();
cache.addTile(dummyTile, "/test/data/A.png", "__TEST__url");
cache.getData("__TEST__longConversionProcessForTesting").then(_ => {
test.ok(conversionHappened, "Interrupted conversion finished.");
test.ok(cache.loaded, "Cache is loaded.");
test.equal(cache.data, "some interesting data", "We got the correct data.");
test.equal(cache.type, "__TEST__longConversionProcessForTesting", "Cache declares new type.");
test.equal(urlDestroy, 1, "Url was destroyed.");
//destruction will likely happen after we finish current async callback
setTimeout(() => {
test.ok(destructionHappened, "Interrupted conversion finished.");
done();
}, 25);
});
test.ok(!cache.loaded, "Cache is still not loaded.");
test.equal(cache.data, undefined, "Cache is still not loaded.");
test.equal(cache.type, "__TEST__longConversionProcessForTesting", "Cache already declares new type.");
cache.destroy();
test.equal(cache.type, "__TEST__longConversionProcessForTesting",
"Type not erased immediatelly as we still process the data.");
test.ok(!conversionHappened, "We destroyed cache before conversion finished.");
});
// TODO: The ultimate integration test:
// three items: one plain image data
// one modified image data by two different plugins
// one modified data by custom code that creates its own cache
// QUnit.test('Manual Data Convertors: testing conversion & destruction', function (test) {
// const done = test.async();
//
//
//
// viewer.world.addHandler('add-item', event => {
// waitFor(() => {
// if (event.item._fullyLoaded) {
//
// }
// });
// });
// viewer.open('/test/data/testpattern.dzi');
// });
})();

View File

@ -33,6 +33,7 @@
<script src="/test/modules/event-source.js"></script>
<script src="/test/modules/viewerretrieval.js"></script>
<script src="/test/modules/basic.js"></script>
<script src="/test/modules/typeConversion.js"></script>
<script src="/test/modules/strings.js"></script>
<script src="/test/modules/formats.js"></script>
<script src="/test/modules/iiif.js"></script>