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 @@ +