From 219049976c3882a8e7cd801a3a72d5f2fba37c15 Mon Sep 17 00:00:00 2001 From: Aiosa Date: Sat, 18 Nov 2023 20:16:35 +0100 Subject: [PATCH] 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. --- src/datatypeconvertor.js | 77 ++++++-- src/imageloader.js | 2 +- src/tile.js | 10 +- src/tilecache.js | 310 +++++++++++---------------------- src/tiledimage.js | 10 +- src/tilesource.js | 4 +- src/viewer.js | 1 - test/modules/tilecache.js | 183 +++++++++++++++++++ test/modules/typeConversion.js | 277 +++++++++++++++++++++++++++++ test/test.html | 1 + 10 files changed, 641 insertions(+), 234 deletions(-) create mode 100644 test/modules/typeConversion.js diff --git a/src/datatypeconvertor.js b/src/datatypeconvertor.js index 0309535f..84c25ef5 100644 --- a/src/datatypeconvertor.js +++ b/src/datatypeconvertor.js @@ -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 diff --git a/src/imageloader.js b/src/imageloader.js index 97cd9f6b..db5c7440 100644 --- a/src/imageloader.js +++ b/src/imageloader.js @@ -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() { diff --git a/src/tile.js b/src/tile.js index 5d84c458..ee0711cd 100644 --- a/src/tile.js +++ b/src/tile.js @@ -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 diff --git a/src/tilecache.js b/src/tilecache.js index b4dd60b9..e41cc1a3 100644 --- a/src/tilecache.js +++ b/src/tilecache.js @@ -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 }); } }; diff --git a/src/tiledimage.js b/src/tiledimage.js index a7a0f273..042cb61c 100644 --- a/src/tiledimage.js +++ b/src/tiledimage.js @@ -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 { diff --git a/src/tilesource.js b/src/tilesource.js index 54d77de9..0accb534 100644 --- a/src/tilesource.js +++ b/src/tilesource.js @@ -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) { diff --git a/src/viewer.js b/src/viewer.js index 3f77a12b..36e61a0d 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -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); } diff --git a/test/modules/tilecache.js b/test/modules/tilecache.js index 0dc603ef..f6f6ac7b 100644 --- a/test/modules/tilecache.js +++ b/test/modules/tilecache.js @@ -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 () { + $('
').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'); + }); + })(); diff --git a/test/modules/typeConversion.js b/test/modules/typeConversion.js new file mode 100644 index 00000000..fb48731c --- /dev/null +++ b/test/modules/typeConversion.js @@ -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 () { + $('
').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'); + // }); + +})(); diff --git a/test/test.html b/test/test.html index 6a28a1cf..14889351 100644 --- a/test/test.html +++ b/test/test.html @@ -33,6 +33,7 @@ +