diff --git a/src/datatypeconvertor.js b/src/datatypeconvertor.js index 84c25ef5..47af2eb0 100644 --- a/src/datatypeconvertor.js +++ b/src/datatypeconvertor.js @@ -181,29 +181,33 @@ $.DataTypeConvertor = class { constructor() { this.graph = new WeightedGraph(); this.destructors = {}; + this.copyings = {}; // Teaching OpenSeadragon built-in conversions: - - 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 imageCreator = (url) => new $.Promise((resolve, reject) => { + const img = new Image(); + img.onerror = img.onabort = reject; + img.onload = () => resolve(img); + img.src = url; + }); + const canvasContextCreator = (imageData) => { const canvas = document.createElement( 'canvas' ); - canvas.width = image.width; - canvas.height = image.height; + canvas.width = imageData.width; + canvas.height = imageData.height; const context = canvas.getContext('2d'); - context.drawImage( image, 0, 0 ); - return canvas; - }, 1, 1); - this.learn("url", "image", url => { - return new $.Promise((resolve, reject) => { - const img = new Image(); - img.onerror = img.onabort = reject; - img.onload = () => resolve(img); - img.src = url; - }); - }, 1, 1); + context.drawImage( imageData, 0, 0 ); + return context; + }; + + this.learn("context2d", "url", ctx => ctx.canvas.toDataURL(), 1, 2); + this.learn("image", "url", image => image.url); + this.learn("image", "context2d", canvasContextCreator, 1, 1); + this.learn("url", "image", imageCreator, 1, 1); + + //Copies + this.learn("image", "image", image => imageCreator(image.src), 1, 1); + this.learn("url", "url", url => url, 0, 1); //strings are immutable, no need to copy + this.learn("context2d", "context2d", ctx => canvasContextCreator(ctx.canvas)); } /** @@ -276,13 +280,17 @@ $.DataTypeConvertor = class { $.console.assert(costPower >= 0 && costPower <= 7, "[DataTypeConvertor] Conversion costPower must be between <0, 7>."); $.console.assert($.isFunction(callback), "[DataTypeConvertor:learn] Callback must be a valid function!"); - //we won't know if somebody added multiple edges, though it will choose some edge anyway - costPower++; - costMultiplier = Math.min(Math.max(costMultiplier, 1), 10 ^ 5); - this.graph.addVertex(from); - this.graph.addVertex(to); - this.graph.addEdge(from, to, costPower * 10 ^ 5 + costMultiplier, callback); - this._known = {}; //invalidate precomputed paths :/ + if (from === to) { + this.copyings[to] = callback; + } else { + //we won't know if somebody added multiple edges, though it will choose some edge anyway + costPower++; + costMultiplier = Math.min(Math.max(costMultiplier, 1), 10 ^ 5); + this.graph.addVertex(from); + this.graph.addVertex(to); + this.graph.addEdge(from, to, costPower * 10 ^ 5 + costMultiplier, callback); + this._known = {}; //invalidate precomputed paths :/ + } } /** @@ -301,6 +309,9 @@ $.DataTypeConvertor = class { * Convert data item x of type 'from' to any of the 'to' types, chosen is the cheapest known conversion. * Data is destroyed upon conversion. For different behavior, implement your conversion using the * path rules obtained from getConversionPath(). + * Note: conversion DOES NOT COPY data if [to] contains type 'from' (e.g., the cheapest conversion is no conversion). + * It automatically calls destructor on immediate types, but NOT on the x and the result. You should call these + * manually if these should be destroyed. * @param {*} x data item to convert * @param {string} from data item type * @param {string} to desired type(s) @@ -315,7 +326,7 @@ $.DataTypeConvertor = class { const stepCount = conversionPath.length, _this = this; - const step = (x, i) => { + const step = (x, i, destroy = true) => { if (i >= stepCount) { return $.Promise.resolve(x); } @@ -326,23 +337,46 @@ $.DataTypeConvertor = class { return $.Promise.resolve(); } //node.value holds the type string - _this.destroy(edge.origin.value, x); + if (destroy) { + _this.destroy(x, edge.origin.value); + } const result = $.type(y) === "promise" ? y : $.Promise.resolve(y); return result.then(res => step(res, i + 1)); }; - return step(x, 0); + //destroy only mid-results, but not the original value + return step(x, 0, false); } /** * Destroy the data item given. * @param {string} type data type * @param {?} data + * @return {OpenSeadragon.Promise|undefined} promise resolution with data passed from constructor */ - destroy(type, data) { + copy(data, type) { + const copyTransform = this.copyings[type]; + if (copyTransform) { + const y = copyTransform(data); + return $.type(y) === "promise" ? y : $.Promise.resolve(y); + } + $.console.warn(`[OpenSeadragon.convertor.copy] is not supported with type %s`, type); + return $.Promise.resolve(undefined); + } + + /** + * Destroy the data item given. + * @param {string} type data type + * @param {?} data + * @return {OpenSeadragon.Promise|undefined} promise resolution with data passed from constructor, or undefined + * if not such conversion exists + */ + destroy(data, type) { const destructor = this.destructors[type]; if (destructor) { - destructor(data); + const y = destructor(data); + return $.type(y) === "promise" ? y : $.Promise.resolve(y); } + return undefined; } /** diff --git a/src/iiiftilesource.js b/src/iiiftilesource.js index 1ef8b80a..afa196cb 100644 --- a/src/iiiftilesource.js +++ b/src/iiiftilesource.js @@ -263,7 +263,7 @@ $.extend( $.IIIFTileSource.prototype, $.TileSource.prototype, /** @lends OpenSea if (data.preferredFormats) { for (var f = 0; f < data.preferredFormats.length; f++ ) { - if ( OpenSeadragon.imageFormatSupported(data.preferredFormats[f]) ) { + if ( $.imageFormatSupported(data.preferredFormats[f]) ) { data.tileFormat = data.preferredFormats[f]; break; } diff --git a/src/openseadragon.js b/src/openseadragon.js index 1659f3bb..93a079e5 100644 --- a/src/openseadragon.js +++ b/src/openseadragon.js @@ -707,6 +707,12 @@ * NOTE: passing POST data from URL by this feature only supports string values, however, * TileSource can send any data using POST as long as the header is correct * (@see OpenSeadragon.TileSource.prototype.getTilePostData) + * + * @property {Boolean} [callTileLoadedWithCachedData=false] + * tile-loaded event is called only for tiles that downloaded new data or + * their data is stored in the original form in a suplementary cache object. + * Caches that render directly from re-used cache does not trigger this event again, + * as possible modifications would be applied twice. */ /** @@ -1207,6 +1213,7 @@ function OpenSeadragon( options ){ loadTilesWithAjax: false, ajaxHeaders: {}, splitHashDataForPost: false, + callTileLoadedWithCachedData: false, //PAN AND ZOOM SETTINGS AND CONSTRAINTS panHorizontal: true, diff --git a/src/tile.js b/src/tile.js index ee0711cd..af3777f1 100644 --- a/src/tile.js +++ b/src/tile.js @@ -284,6 +284,10 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja * @private */ this._caches = {}; + /** + * @private + */ + this._cacheSize = 0; }; /** @lends OpenSeadragon.Tile.prototype */ @@ -360,7 +364,7 @@ $.Tile.prototype = { * @returns {Image} */ get image() { - $.console.error("[Tile.image] property has been deprecated. Use [Tile.prototype.getImage] instead."); + $.console.error("[Tile.image] property has been deprecated. Use [Tile.getData] instead."); return this.getImage(); }, @@ -372,7 +376,7 @@ $.Tile.prototype = { * @returns {String} */ get url() { - $.console.error("[Tile.url] property has been deprecated. Use [Tile.prototype.getUrl] instead."); + $.console.error("[Tile.url] property has been deprecated. Use [Tile.getUrl] instead."); return this.getUrl(); }, @@ -381,7 +385,14 @@ $.Tile.prototype = { * @returns {?Image} */ getImage: function() { - return this.getData("image"); + //TODO: after merge $.console.error("[Tile.getImage] property has been deprecated. Use [Tile.getData] instead."); + //this method used to ensure the underlying data model conformed to given type - convert instead of getData() + const cache = this.getCache(this.cacheKey); + if (!cache) { + return undefined; + } + cache.transformTo("image"); + return cache.data; }, /** @@ -402,7 +413,14 @@ $.Tile.prototype = { * @returns {?CanvasRenderingContext2D} */ getCanvasContext: function() { - return this.getData("context2d"); + //TODO: after merge $.console.error("[Tile.getCanvasContext] property has been deprecated. Use [Tile.getData] instead."); + //this method used to ensure the underlying data model conformed to given type - convert instead of getData() + const cache = this.getCache(this.cacheKey); + if (!cache) { + return undefined; + } + cache.transformTo("context2d"); + return cache.data; }, /** @@ -411,8 +429,8 @@ $.Tile.prototype = { * @type {CanvasRenderingContext2D} context2D */ get context2D() { - $.console.error("[Tile.context2D] property has been deprecated. Use Tile::getCache()."); - return this.getData("context2d"); + $.console.error("[Tile.context2D] property has been deprecated. Use [Tile.getData] instead."); + return this.getCanvasContext(); }, /** @@ -420,7 +438,7 @@ $.Tile.prototype = { * @deprecated */ set context2D(value) { - $.console.error("[Tile.context2D] property has been deprecated. Use Tile::setCache()."); + $.console.error("[Tile.context2D] property has been deprecated. Use [Tile.setData] instead."); this.setData(value, "context2d"); }, @@ -440,49 +458,63 @@ $.Tile.prototype = { */ set cacheImageRecord(value) { $.console.error("[Tile.cacheImageRecord] property has been deprecated. Use Tile::setCache."); - this._caches[this.cacheKey] = value; + const cache = this._caches[this.cacheKey]; + + if (!value) { + this.unsetCache(this.cacheKey); + } else { + const _this = this; + cache.await().then(x => _this.setCache(this.cacheKey, x, cache.type, false)); + } }, /** * Get the default data for this tile - * @param {?string} [type=undefined] data type to require + * @param {string} type data type to require + * @param {boolean?} [copy=this.loaded] whether to force copy retrieval * @return {*|undefined} data in the desired type, or undefined if a conversion is ongoing */ - getData(type = undefined) { + getData: function(type, copy = this.loaded) { + //we return the data synchronously immediatelly (undefined if conversion happens) const cache = this.getCache(this.cacheKey); if (!cache) { + $.console.error("[Tile::getData] There is no cache available for tile with key " + this.cacheKey); return undefined; } - cache.getData(type); //returns a promise - //we return the data synchronously immediatelly (undefined if conversion happens) - return cache.data; - }, - - /** - * Invalidate the tile so that viewport gets updated. - */ - save() { - const parent = this.tiledImage; - if (parent) { - parent._needsDraw = true; - } + return cache.getDataAs(type, copy); }, /** * Set cache data * @param {*} value - * @param {?string} [type=undefined] data type to require + * @param {?string} type data type to require + * @param {boolean} [preserveOriginalData=true] if true and cacheKey === originalCacheKey, + * then stores the underlying data as 'original' and changes the cacheKey to point + * to a new data. This makes the Tile assigned to two cache objects. */ - setData(value, type = undefined) { - this.setCache(this.cacheKey, value, type); + setData: function(value, type, preserveOriginalData = true) { + if (preserveOriginalData && this.cacheKey === this.originalCacheKey) { + //caches equality means we have only one cache: + // change current pointer to a new cache and create it: new tiles will + // not arrive at this data, but at originalCacheKey state + this.cacheKey = "mod://" + this.originalCacheKey; + return this.setCache(this.cacheKey, value, type)._promise; + } + //else overwrite cache + const cache = this.getCache(this.cacheKey); + if (!cache) { + $.console.error("[Tile::setData] There is no cache available for tile with key " + this.cacheKey); + return $.Promise.resolve(); + } + return cache.setDataAs(value, type); }, /** * Read tile cache data object (CacheRecord) - * @param {string} key cache key to read that belongs to this tile + * @param {string?} [key=this.cacheKey] cache key to read that belongs to this tile * @return {OpenSeadragon.CacheRecord} */ - getCache: function(key) { + getCache: function(key = this.cacheKey) { return this._caches[key]; }, @@ -495,12 +527,15 @@ $.Tile.prototype = { * @param {?string} type data type, will be guessed if not provided * @param [_safely=true] private * @param [_cutoff=0] private + * @returns {OpenSeadragon.CacheRecord} - The cache record the tile was attached to. */ setCache: function(key, data, type = undefined, _safely = true, _cutoff = 0) { - 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; + if (!type) { + if (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); } @@ -508,18 +543,54 @@ $.Tile.prototype = { //todo later, we could have drawers register their supported rendering type // and OpenSeadragon would check compatibility automatically, now we render // using two main types so we check their ability - const conversion = $.convertor.getConversionPath(type, "canvas", "image"); + const conversion = $.convertor.getConversionPath(type, "context2d"); $.console.assert(conversion, "[Tile.setCache] data was set for the default tile cache we are unable" + "to render. Make sure OpenSeadragon.convertor was taught to convert type: " + type); } - this.tiledImage._tileCache.cacheTile({ + const cachedItem = this.tiledImage._tileCache.cacheTile({ data: data, dataType: type, tile: this, cacheKey: key, cutoff: _cutoff }); + const havingRecord = this._caches[key]; + if (havingRecord !== cachedItem) { + if (!havingRecord) { + this._cacheSize++; + } + this._caches[key] = cachedItem; + } + return cachedItem; + }, + + /** + * Get the number of caches available to this tile + * @returns {number} number of caches + */ + getCacheSize: function() { + return this._cacheSize; + }, + + /** + * Free tile cache. Removes by default the cache record if no other tile uses it. + * @param {string} key cache key, required + * @param {boolean} [freeIfUnused=true] set to false if zombie should be created + */ + unsetCache: function(key, freeIfUnused = true) { + if (this.cacheKey === key) { + if (this.cacheKey !== this.originalCacheKey) { + this.cacheKey = this.originalCacheKey; + } else { + $.console.warn("[Tile.unsetCache] trying to remove the only cache that is used to draw the tile!"); + } + } + if (this.tiledImage._tileCache.unloadCacheForTile(this, key, freeIfUnused)) { + //if we managed to free tile from record, we are sure we decreased cache count + this._cacheSize--; + delete this._caches[key]; + } }, /** @@ -680,10 +751,12 @@ $.Tile.prototype = { this.tiledImage = null; this._caches = []; + this._cacheSize = 0; this.element = null; this.imgElement = null; this.loaded = false; this.loading = false; + this.cacheKey = this.originalCacheKey; } }; diff --git a/src/tilecache.js b/src/tilecache.js index e41cc1a3..181d8071 100644 --- a/src/tilecache.js +++ b/src/tilecache.js @@ -47,65 +47,197 @@ * * @typedef {{ * destroy: function, + * revive: function, * save: function, - * getData: function, + * getDataAs: function, + * transformTo: function, * data: ?, * loaded: boolean * }} OpenSeadragon.CacheRecord */ $.CacheRecord = class { constructor() { - this._tiles = []; - this._data = null; - this.loaded = false; - this._promise = $.Promise.resolve(); - } - - destroy() { - //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._tiles = null; - this._data = null; - this._type = null; - this._promise = $.Promise.resolve(); - }); - } - this.loaded = false; + this.revive(); } + /** + * Access the cache record data directly. Preferred way of data access. + * Might be undefined if this.loaded = false. + * You can access the data in synchronous way, but the data might not be available. + * If you want to access the data indirectly (await), use this.transformTo or this.getDataAs + * @return {any} + */ get data() { return this._data; } + /** + * Read the cache type. The type can dynamically change, but should be consistent at + * one point in the time. For available types see the OpenSeadragon.Convertor, or the tutorials. + * @return {string} + */ get type() { return this._type; } - save() { - for (let tile of this._tiles) { - tile._needsDraw = true; + /** + * Await ongoing process so that we get cache ready on callback. + * @returns {null|*} + */ + await() { + if (!this._promise) { //if not cache loaded, do not fail + return $.Promise.resolve(); } + return this._promise; } - getData(type = this._type) { - if (type !== this._type) { + getImage() { + $.console.error("[CacheRecord.getImage] options.image is deprecated. Moreover, it might not work" + + " correctly as the cache system performs conversion asynchronously in case the type needs to be converted."); + this.transformTo("image"); + return this.data; + } + + getRenderedContext() { + $.console.error("[CacheRecord.getRenderedContext] options.getRenderedContext is deprecated. Moreover, it might not work" + + " correctly as the cache system performs conversion asynchronously in case the type needs to be converted."); + this.transformTo("context2d"); + return this.data; + } + + /** + * Set the cache data. Asynchronous. + * @param {any} data + * @param {string} type + * @returns {OpenSeadragon.Promise} the old cache data that has been overwritten + */ + setDataAs(data, type) { + //allow set data with destroyed state, destroys the data if necessary + $.console.assert(data !== undefined, "[CacheRecord.setDataAs] needs valid data to set!"); + if (this._conversionJobQueue) { + //delay saving if ongiong conversion, these were registered first + let resolver = null; + const promise = new $.Promise((resolve, reject) => { + resolver = resolve; + }); + this._conversionJobQueue.push(() => resolver(this._overwriteData(data, type))); + return promise; + } + return this._overwriteData(data, type); + } + + /** + * Access the cache record data indirectly. Preferred way of data access. Asynchronous. + * @param {string?} [type=this.type] + * @param {boolean?} [copy=true] if false and same type is retrieved as the cache type, + * copy is not performed: note that this is potentially dangerous as it might + * introduce race conditions (you get a cache data direct reference you modify, + * but others might also access it, for example drawers to draw the viewport). + * @returns {OpenSeadragon.Promise} desired data type in promise, undefined if the cache was destroyed + */ + getDataAs(type = this._type, copy = true) { + if (this.loaded && type === this._type) { + return copy ? $.convertor.copy(this._data, type) : this._promise; + } + + return this._promise.then(data => { + //might get destroyed in meanwhile + if (this._destroyed) { + return undefined; + } + if (type !== this._type) { + return $.convertor.convert(data, this._type, type); + } + if (copy) { //convert does not copy data if same type, do explicitly + return $.convertor.copy(data, type); + } + return data; + }); + } + + /** + * Transform cache to desired type and get the data after conversion. + * Does nothing if the type equals to the current type. Asynchronous. + * @param {string} type + * @return {OpenSeadragon.Promise|*} + */ + transformTo(type = this._type) { + if (!this.loaded || type !== this._type) { if (!this.loaded) { - $.console.warn("Attempt to call getData with desired type %s, the tile data type is %s and the tile is not loaded!", type, this._type); - return this._promise; + this._conversionJobQueue = this._conversionJobQueue || []; + let resolver = null; + const promise = new $.Promise((resolve, reject) => { + resolver = resolve; + }); + this._conversionJobQueue.push(() => { + if (this._destroyed) { + return; + } + if (type !== this._type) { + //ensures queue gets executed after finish + this._convert(this._type, type); + this._promise.then(data => resolver(data)); + } else { + //must ensure manually, but after current promise finished, we won't wait for the following job + this._promise.then(data => { + this._checkAwaitsConvert(); + return resolver(data); + }); + } + }); + return promise; } this._convert(this._type, type); } return this._promise; } + /** + * Set initial state, prepare for usage. + * Must not be called on active cache, e.g. first call destroy(). + */ + revive() { + $.console.assert(!this.loaded && !this._type, "[CacheRecord::revive] must not be called when loaded!"); + this._tiles = []; + this._data = null; + this._type = null; + this.loaded = false; + this._promise = null; + this._destroyed = false; + } + + /** + * Free all the data and call data destructors if defined. + */ + destroy() { + delete this._conversionJobQueue; + this._destroyed = true; + + //make sure this gets destroyed even if loaded=false + if (this.loaded) { + $.convertor.destroy(this._data, this._type); + this._tiles = null; + this._data = null; + this._type = null; + this._promise = null; + } else { + const oldType = this._type; + this._promise.then(x => { + //ensure old data destroyed + $.convertor.destroy(x, oldType); + //might get revived... + if (!this._destroyed) { + return; + } + this._tiles = null; + this._data = null; + this._type = null; + this._promise = null; + }); + } + this.loaded = false; + } + /** * Add tile dependency on this record * @param tile @@ -113,6 +245,9 @@ $.CacheRecord = class { * @param type */ addTile(tile, data, type) { + if (this._destroyed) { + return; + } $.console.assert(tile, '[CacheRecord.addTile] tile is required'); //allow overriding the cache - existing tile or different type @@ -124,28 +259,28 @@ $.CacheRecord = class { this._promise = $.Promise.resolve(data); this._data = data; this.loaded = true; - } else if (this._type !== type) { - //pass: the tile data type will silently change - // as it inherits this cache - // todo do not call events? } - + //else pass: the tile data type will silently change as it inherits this cache this._tiles.push(tile); } /** * Remove tile dependency on this record. * @param tile + * @returns {Boolean} true if record removed */ removeTile(tile) { + if (this._destroyed) { + return false; + } for (let i = 0; i < this._tiles.length; i++) { if (this._tiles[i] === tile) { this._tiles.splice(i, 1); - return; + return true; } } - $.console.warn('[CacheRecord.removeTile] trying to remove unknown tile', tile); + return false; } /** @@ -153,7 +288,57 @@ $.CacheRecord = class { * @return {number} */ getTileCount() { - return this._tiles.length; + return this._tiles ? this._tiles.length : 0; + } + + /** + * Private conversion that makes sure collided requests are + * processed eventually + * @private + */ + _checkAwaitsConvert() { + if (!this._conversionJobQueue || this._destroyed) { + return; + } + //let other code finish first + setTimeout(() => { + //check again, meanwhile things might've changed + if (!this._conversionJobQueue || this._destroyed) { + return; + } + const job = this._conversionJobQueue[0]; + this._conversionJobQueue.splice(0, 1); + if (this._conversionJobQueue.length === 0) { + delete this._conversionJobQueue; + } + job(); + }); + } + + /** + * Safely overwrite the cache data and return the old data + * @private + */ + _overwriteData(data, type) { + if (this._destroyed) { + //we take ownership of the data, destroy + $.convertor.destroy(data, type); + return $.Promise.resolve(); + } + if (this.loaded) { + $.convertor.destroy(this._data, this._type); + this._type = type; + this._data = data; + this._promise = $.Promise.resolve(data); + return this._promise; + } + return this._promise.then(x => { + $.convertor.destroy(x, this._type); + this._type = type; + this._data = data; + this._promise = $.Promise.resolve(data); + return x; + }); } /** @@ -175,6 +360,7 @@ $.CacheRecord = class { if (i >= stepCount) { _this._data = x; _this.loaded = true; + _this._checkAwaitsConvert(); return $.Promise.resolve(x); } let edge = conversionPath[i]; @@ -189,7 +375,7 @@ $.CacheRecord = class { return originalData; } //node.value holds the type string - convertor.destroy(edge.origin.value, x); + convertor.destroy(x, edge.origin.value); return convert(y, i + 1); } ); @@ -232,15 +418,23 @@ $.TileCache = class { return this._tilesLoaded.length; } + /** + * @returns {Number} The total number of cached objects (+ zombies) + */ + numCachesLoaded() { + return this._zombiesLoadedCount + this._cachesLoadedCount; + } + /** * Caches the specified tile, removing an old tile if necessary to stay under the * maxImageCacheCount specified on construction. Note that if multiple tiles reference * the same image, there may be more tiles than maxImageCacheCount; the goal is to keep * the number of images below that number. Note, as well, that even the number of images * may temporarily surpass that number, but should eventually come back down to the max specified. + * @private * @param {Object} options - Tile info. * @param {OpenSeadragon.Tile} options.tile - The tile to cache. - * @param {String} [options.cacheKey=undefined] - Cache Key to use. Defaults to options.tile.cacheKey + * @param {?String} [options.cacheKey=undefined] - Cache Key to use. Defaults to options.tile.cacheKey * @param {String} options.tile.cacheKey - The unique key used to identify this tile in the cache. * Used if cacheKey not set. * @param {Image} options.image - The image of the tile to cache. Deprecated. @@ -249,30 +443,33 @@ $.TileCache = class { * @param {Number} [options.cutoff=0] - If adding this tile goes over the cache max count, this * function will release an old tile. The cutoff option specifies a tile level at or below which * tiles will not be released. + * @returns {OpenSeadragon.CacheRecord} - The cache record the tile was attached to. */ cacheTile( options ) { $.console.assert( options, "[TileCache.cacheTile] options is required" ); - $.console.assert( options.tile, "[TileCache.cacheTile] options.tile is required" ); - $.console.assert( options.tile.cacheKey, "[TileCache.cacheTile] options.tile.cacheKey is required" ); + const theTile = options.tile; + $.console.assert( theTile, "[TileCache.cacheTile] options.tile is required" ); + $.console.assert( theTile.cacheKey, "[TileCache.cacheTile] options.tile.cacheKey is required" ); let cutoff = options.cutoff || 0, insertionIndex = this._tilesLoaded.length, - cacheKey = options.cacheKey || options.tile.cacheKey; + cacheKey = options.cacheKey || theTile.cacheKey; let cacheRecord = this._cachesLoaded[cacheKey] || this._zombiesLoaded[cacheKey]; if (!cacheRecord) { - - if (!options.data) { + if (options.data === undefined) { $.console.error("[TileCache.cacheTile] options.image was renamed to options.data. '.image' attribute " + "has been deprecated and will be removed in the future."); options.data = options.image; } - $.console.assert( options.data, "[TileCache.cacheTile] options.data is required to create an CacheRecord" ); + //allow anything but undefined, null, false (other values mean the data was set, for example '0') + $.console.assert( options.data !== undefined && options.data !== null && options.data !== false, + "[TileCache.cacheTile] options.data is required to create an CacheRecord" ); cacheRecord = this._cachesLoaded[cacheKey] = new $.CacheRecord(); this._cachesLoadedCount++; - } else if (!cacheRecord.getTileCount()) { - //revive zombie + } else if (cacheRecord._destroyed) { + cacheRecord.revive(); delete this._zombiesLoaded[cacheKey]; this._zombiesLoadedCount--; } @@ -282,11 +479,12 @@ $.TileCache = class { "For easier use of the cache system, use the tile instance API."); options.dataType = $.convertor.guessType(options.data); } - cacheRecord.addTile(options.tile, options.data, options.dataType); - options.tile._caches[ cacheKey ] = cacheRecord; + + cacheRecord.addTile(theTile, options.data, options.dataType); // 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. + let worstTileIndex = -1; if ( this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount ) { //prefer zombie deletion, faster, better if (this._zombiesLoadedCount > 0) { @@ -297,8 +495,7 @@ $.TileCache = class { break; } } else { - let worstTile = null; - let worstTileIndex = -1; + let worstTile = null; let prevTile, worstTime, worstLevel, prevTime, prevLevel; for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) { @@ -325,13 +522,20 @@ $.TileCache = class { } if ( worstTile && worstTileIndex >= 0 ) { - this._unloadTile(worstTile, true); + this.unloadTile(worstTile, true); insertionIndex = worstTileIndex; } } } - this._tilesLoaded[ insertionIndex ] = options.tile; + if (theTile.getCacheSize() === 0) { + this._tilesLoaded[ insertionIndex ] = theTile; + } else if (worstTileIndex >= 0) { + //tile is already recorded, do not add tile, but remove the tile at insertion index + this._tilesLoaded.splice(insertionIndex, 1); + } + + return cacheRecord; } /** @@ -344,7 +548,7 @@ $.TileCache = class { let cacheOverflows = this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount; if (tiledImage._zombieCache && cacheOverflows && this._zombiesLoadedCount > 0) { - //prefer newer zombies + //prefer newer (fresh ;) zombies for (let zombie in this._zombiesLoaded) { this._zombiesLoaded[zombie].destroy(); delete this._zombiesLoaded[zombie]; @@ -355,22 +559,60 @@ $.TileCache = class { for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) { tile = this._tilesLoaded[ i ]; - //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 ) { - //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); + if (tile.tiledImage === tiledImage) { + if (!tile.loaded) { + //iterates from the array end, safe to remove + this._tilesLoaded.splice( i, 1 ); + } else if ( tile.tiledImage === tiledImage ) { + this.unloadTile(tile, !tiledImage._zombieCache || cacheOverflows, i); + } } } } - // private + /** + * Get cache record (might be a unattached record, i.e. a zombie) + * @param cacheKey + * @returns {OpenSeadragon.CacheRecord|undefined} + */ getCacheRecord(cacheKey) { $.console.assert(cacheKey, '[TileCache.getCacheRecord] cacheKey is required'); - return this._cachesLoaded[cacheKey]; + return this._cachesLoaded[cacheKey] || this._zombiesLoaded[cacheKey]; + } + + /** + * Delete cache record for a given til + * @param {OpenSeadragon.Tile} tile + * @param {string} key cache key + * @param {boolean} destroy if true, empty cache is destroyed, else left as a zombie + * @private + */ + unloadCacheForTile(tile, key, destroy) { + const cacheRecord = this._cachesLoaded[key]; + //unload record only if relevant - the tile exists in the record + if (cacheRecord) { + if (cacheRecord.removeTile(tile)) { + if (!cacheRecord.getTileCount()) { + if (destroy) { + // #1 tile marked as destroyed (e.g. too much cached tiles or not a zombie) + cacheRecord.destroy(); + } else { + // #2 Tile is a zombie. Do not delete record, reuse. + this._zombiesLoaded[key] = cacheRecord; + this._zombiesLoadedCount++; + } + // Either way clear cache + delete this._cachesLoaded[key]; + this._cachesLoadedCount--; + } + return true; + } + $.console.error("[TileCache.unloadCacheForTile] System tried to delete tile from cache it " + + "does not belong to! This could mean a bug in the cache system."); + return false; + } + $.console.warn("[TileCache.unloadCacheForTile] Attempting to delete missing cache!"); + return false; } /** @@ -379,36 +621,19 @@ $.TileCache = class { * @param deleteAtIndex index to remove the tile record at, will not remove from _tiledLoaded if not set * @private */ - _unloadTile(tile, destroy, deleteAtIndex) { - $.console.assert(tile, '[TileCache._unloadTile] tile is required'); + unloadTile(tile, destroy, deleteAtIndex) { + $.console.assert(tile, '[TileCache.unloadTile] tile is required'); for (let key in tile._caches) { - const cacheRecord = this._cachesLoaded[key]; - if (cacheRecord) { - cacheRecord.removeTile(tile); - if (!cacheRecord.getTileCount()) { - if (destroy) { - // #1 tile marked as destroyed (e.g. too much cached tiles or not a zombie) - cacheRecord.destroy(); - delete this._cachesLoaded[tile.cacheKey]; - this._cachesLoadedCount--; - } else if (deleteAtIndex !== undefined) { - // #2 Tile is a zombie. Do not delete record, reuse. - 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. - this._tilesLoaded.splice( deleteAtIndex, 1 ); - } - } else { - $.console.warn("[TileCache._unloadTile] Attempting to delete missing cache!"); - } + //we are 'ok' to remove tile caches here since we later call destroy on tile, otherwise + //tile has count of its cache size --> would be inconsistent + this.unloadCacheForTile(tile, key, destroy); } + //delete also the tile record + if (deleteAtIndex !== undefined) { + this._tilesLoaded.splice( deleteAtIndex, 1 ); + } + const tiledImage = tile.tiledImage; tile.unload(); diff --git a/src/tiledimage.js b/src/tiledimage.js index 5fe09c26..411edae1 100644 --- a/src/tiledimage.js +++ b/src/tiledimage.js @@ -83,6 +83,8 @@ * Defaults to the setting in {@link OpenSeadragon.Options}. * @param {Object} [options.ajaxHeaders={}] * A set of headers to include when making tile AJAX requests. + * @param {Boolean} [options.callTileLoadedWithCachedData] + * Invoke tile-loded event for also for tiles loaded from cache if true. */ $.TiledImage = function( options ) { var _this = this; @@ -184,7 +186,8 @@ $.TiledImage = function( options ) { preload: $.DEFAULT_SETTINGS.preload, compositeOperation: $.DEFAULT_SETTINGS.compositeOperation, subPixelRoundingForTransparency: $.DEFAULT_SETTINGS.subPixelRoundingForTransparency, - maxTilesPerFrame: $.DEFAULT_SETTINGS.maxTilesPerFrame + maxTilesPerFrame: $.DEFAULT_SETTINGS.maxTilesPerFrame, + callTileLoadedWithCachedData: $.DEFAULT_SETTINGS.callTileLoadedWithCachedData }, options ); this._preload = this.preload; @@ -1531,28 +1534,10 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag ); if (!tile.loaded && !tile.loading) { - // Tile was created or its data removed: check whether cache has the data before downloading. - if (!tile.cacheKey) { - tile.cacheKey = ""; - tile.originalCacheKey = ""; - } - - //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 { - similarCacheRecord.getData().then(data => - this._setTileLoaded(tile, data, cutoff, null, similarCacheRecord.type)); - } - } + // Tile was created or its data removed: check whether cache has the data. + // this method sets tile.loading=true if data available, which prevents + // job creation later on + this._tryFindTileCacheRecord(tile); } if ( tile.loaded ) { @@ -1577,6 +1562,45 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag return best; }, + /** + * @private + * @inner + * Try to find existing cache of the tile + * @param {OpenSeadragon.Tile} tile + */ + _tryFindTileCacheRecord: function(tile) { + if (!tile.cacheKey) { + tile.cacheKey = ""; + tile.originalCacheKey = ""; + } + + let record = this._tileCache.getCacheRecord(tile.cacheKey); + const cutoff = this.source.getClosestLevel(); + + if (record) { + //setup without calling tile loaded event! tile cache is ready for usage, + tile.loading = true; + tile.loaded = false; + //set data as null, cache already has data, it does not overwrite + this._setTileLoaded(tile, null, cutoff, null, record.type, + this.callTileLoadedWithCachedData); + return true; + } + + if (tile.cacheKey !== tile.originalCacheKey) { + //we found original data: this data will be used to re-execute the pipeline + record = this._tileCache.getCacheRecord(tile.originalCacheKey); + if (record) { + tile.loading = true; + tile.loaded = false; + //set data as null, cache already has data, it does not overwrite + this._setTileLoaded(tile, null, cutoff, null, record.type); + return true; + } + } + return false; + }, + /** * @private * @inner @@ -1779,8 +1803,9 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @param {?Number} cutoff * @param {?XMLHttpRequest} tileRequest * @param {?String} [dataType=undefined] data type, derived automatically if not set + * @param {?Boolean} [withEvent=true] do not trigger event if true */ - _setTileLoaded: function(tile, data, cutoff, tileRequest, dataType) { + _setTileLoaded: function(tile, data, cutoff, tileRequest, dataType, withEvent = true) { tile.tiledImage = this; //unloaded with tile.unload(), so we need to set it back // -> reason why it is not in the constructor tile.setCache(tile.cacheKey, data, dataType, false, cutoff); @@ -1811,7 +1836,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag resolver(tile); } else if (cache.type !== requiredType) { //initiate conversion as soon as possible if incompatible with the drawer - cache.getData(requiredType).then(_ => { + cache.transformTo(requiredType).then(_ => { tile.loading = false; tile.loaded = true; resolver(tile); @@ -1821,12 +1846,6 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag tile.loaded = true; resolver(tile); } - - //FIXME: design choice: cache tile now set automatically so users can do - // tile.getCache(...) inside this event, but maybe we would like to have users - // freedom to decide on the cache creation (note, tiles now MUST have cache, e.g. - // it is no longer possible to store all tiles in the memory as it was with context2D prop) - tile.save(); } function getCompletionCallback() { @@ -1839,6 +1858,10 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag } const fallbackCompletion = getCompletionCallback(); + if (!withEvent) { + fallbackCompletion(); + return; + } /** * Triggered when a tile has just been loaded in memory. That means that the diff --git a/src/tilesource.js b/src/tilesource.js index d9b6e35a..33124ca4 100644 --- a/src/tilesource.js +++ b/src/tilesource.js @@ -915,7 +915,8 @@ $.TileSource.prototype = { * @deprecated */ getTileCacheData: function(cacheObject) { - return cacheObject.getData(); + $.console.error("[TileSource.getTileCacheData] has been deprecated. Use cache API of a tile instead."); + return cacheObject.getDataAs(undefined, false); }, /** @@ -930,7 +931,7 @@ $.TileSource.prototype = { */ getTileCacheDataAsImage: function(cacheObject) { $.console.error("[TileSource.getTileCacheDataAsImage] has been deprecated. Use cache API of a tile instead."); - return cacheObject.getData("image"); + return cacheObject.getImage(); }, /** @@ -944,7 +945,7 @@ $.TileSource.prototype = { */ getTileCacheDataAsContext2D: function(cacheObject) { $.console.error("[TileSource.getTileCacheDataAsContext2D] has been deprecated. Use cache API of a tile instead."); - return cacheObject.getData("context2d"); + return cacheObject.getRenderedContext(); } }; diff --git a/src/viewer.js b/src/viewer.js index 561ee212..e5b41abc 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -1623,7 +1623,8 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, loadTilesWithAjax: queueItem.options.loadTilesWithAjax, ajaxHeaders: queueItem.options.ajaxHeaders, debugMode: _this.debugMode, - subPixelRoundingForTransparency: _this.subPixelRoundingForTransparency + subPixelRoundingForTransparency: _this.subPixelRoundingForTransparency, + callTileLoadedWithCachedData: _this.callTileLoadedWithCachedData, }); if (_this.collectionMode) { diff --git a/src/zoomifytilesource.js b/src/zoomifytilesource.js index 1da5eece..9f39dc04 100644 --- a/src/zoomifytilesource.js +++ b/src/zoomifytilesource.js @@ -77,7 +77,7 @@ options.minLevel = 0; options.maxLevel = options.gridSize.length - 1; - OpenSeadragon.TileSource.apply(this, [options]); + $.TileSource.apply(this, [options]); }; $.extend($.ZoomifyTileSource.prototype, $.TileSource.prototype, /** @lends OpenSeadragon.ZoomifyTileSource.prototype */ { diff --git a/test/modules/tilecache.js b/test/modules/tilecache.js index deb0eaf7..9eafaec9 100644 --- a/test/modules/tilecache.js +++ b/test/modules/tilecache.js @@ -1,6 +1,9 @@ /* global QUnit, testLog */ (function() { + const Convertor = OpenSeadragon.convertor, + T_A = "__TEST__typeA", T_B = "__TEST__typeB", T_C = "__TEST__typeC", T_D = "__TEST__typeD", T_E = "__TEST__typeE"; + let viewer; //we override jobs: remember original function @@ -15,6 +18,82 @@ }, 20); } + function createFakeTile(url, tiledImage, loading=false, loaded=true) { + const dummyRect = new OpenSeadragon.Rect(0, 0, 0, 0, 0); + //default cutoof = 0 --> use level 1 to not to keep caches from unloading (cutoff = navigator data, kept in cache) + const dummyTile = new OpenSeadragon.Tile(1, 0, 0, dummyRect, true, url, + undefined, true, null, dummyRect, null, url); + dummyTile.tiledImage = tiledImage; + dummyTile.loading = loading; + dummyTile.loaded = loaded; + return dummyTile; + } + + // Replace conversion with our own system and test: __TEST__ prefix must be used, otherwise + // other tests will interfere + let typeAtoB = 0, typeBtoC = 0, typeCtoA = 0, typeDtoA = 0, typeCtoE = 0; + //set all same costs to get easy testing, know which path will be taken + Convertor.learn(T_A, T_B, x => { + typeAtoB++; + return x+1; + }); + Convertor.learn(T_B, T_C, x => { + typeBtoC++; + return x+1; + }); + Convertor.learn(T_C, T_A, x => { + typeCtoA++; + return x+1; + }); + Convertor.learn(T_D, T_A, x => { + typeDtoA++; + return x+1; + }); + Convertor.learn(T_C, T_E, x => { + typeCtoE++; + return x+1; + }); + //'Copy constructors' + let copyA = 0, copyB = 0, copyC = 0, copyD = 0, copyE = 0; + //also learn destructors + Convertor.learn(T_A, T_A,x => { + copyA++; + return x+1; + }); + Convertor.learn(T_B, T_B,x => { + copyB++; + return x+1; + }); + Convertor.learn(T_C, T_C,x => { + copyC++; + return x-1; + }); + Convertor.learn(T_D, T_D,x => { + copyD++; + return x+1; + }); + Convertor.learn(T_E, T_E,x => { + copyE++; + return x+1; + }); + let destroyA = 0, destroyB = 0, destroyC = 0, destroyD = 0, destroyE = 0; + //also learn destructors + Convertor.learnDestroy(T_A, () => { + destroyA++; + }); + Convertor.learnDestroy(T_B, () => { + destroyB++; + }); + Convertor.learnDestroy(T_C, () => { + destroyC++; + }); + Convertor.learnDestroy(T_D, () => { + destroyD++; + }); + Convertor.learnDestroy(T_E, () => { + destroyE++; + }); + // ---------- QUnit.module('TileCache', { beforeEach: function () { @@ -42,54 +121,38 @@ // ---------- // TODO: this used to be async QUnit.test('basics', function(assert) { - var done = assert.async(); - var fakeViewer = { + const done = assert.async(); + const fakeViewer = { raiseEvent: function() {} }; - var fakeTiledImage0 = { + const fakeTiledImage0 = { viewer: fakeViewer, source: OpenSeadragon.TileSource.prototype }; - var fakeTiledImage1 = { + const fakeTiledImage1 = { viewer: fakeViewer, source: OpenSeadragon.TileSource.prototype }; - var fakeTile0 = { - url: 'foo.jpg', - cacheKey: 'foo.jpg', - image: {}, - loaded: true, - tiledImage: fakeTiledImage0, - _caches: [], - unload: function() {} - }; + const tile0 = createFakeTile('foo.jpg', fakeTiledImage0); + const tile1 = createFakeTile('foo.jpg', fakeTiledImage1); - var fakeTile1 = { - url: 'foo.jpg', - cacheKey: 'foo.jpg', - image: {}, - loaded: true, - tiledImage: fakeTiledImage1, - _caches: [], - unload: function() {} - }; - - var cache = new OpenSeadragon.TileCache(); + const cache = new OpenSeadragon.TileCache(); assert.equal(cache.numTilesLoaded(), 0, 'no tiles to begin with'); - cache.cacheTile({ - tile: fakeTile0, + tile0._caches[tile0.cacheKey] = cache.cacheTile({ + tile: tile0, tiledImage: fakeTiledImage0 }); + tile0._cacheSize++; assert.equal(cache.numTilesLoaded(), 1, 'tile count after cache'); - cache.cacheTile({ - tile: fakeTile1, + tile1._caches[tile1.cacheKey] = cache.cacheTile({ + tile: tile1, tiledImage: fakeTiledImage1 }); - + tile1._cacheSize++; assert.equal(cache.numTilesLoaded(), 2, 'tile count after second cache'); cache.clearTilesFor(fakeTiledImage0); @@ -105,75 +168,369 @@ // ---------- QUnit.test('maxImageCacheCount', function(assert) { - var done = assert.async(); - var fakeViewer = { + const done = assert.async(); + const fakeViewer = { raiseEvent: function() {} }; - var fakeTiledImage0 = { + const fakeTiledImage0 = { viewer: fakeViewer, source: OpenSeadragon.TileSource.prototype }; - var fakeTile0 = { - url: 'different.jpg', - cacheKey: 'different.jpg', - image: {}, - loaded: true, - tiledImage: fakeTiledImage0, - _caches: [], - unload: function() {} - }; + const tile0 = createFakeTile('different.jpg', fakeTiledImage0); + const tile1 = createFakeTile('same.jpg', fakeTiledImage0); + const tile2 = createFakeTile('same.jpg', fakeTiledImage0); - var fakeTile1 = { - url: 'same.jpg', - cacheKey: 'same.jpg', - image: {}, - loaded: true, - tiledImage: fakeTiledImage0, - _caches: [], - unload: function() {} - }; - - var fakeTile2 = { - url: 'same.jpg', - cacheKey: 'same.jpg', - image: {}, - loaded: true, - tiledImage: fakeTiledImage0, - _caches: [], - unload: function() {} - }; - - var cache = new OpenSeadragon.TileCache({ + const cache = new OpenSeadragon.TileCache({ maxImageCacheCount: 1 }); assert.equal(cache.numTilesLoaded(), 0, 'no tiles to begin with'); - cache.cacheTile({ - tile: fakeTile0, + tile0._caches[tile0.cacheKey] = cache.cacheTile({ + tile: tile0, tiledImage: fakeTiledImage0 }); + tile0._cacheSize++; assert.equal(cache.numTilesLoaded(), 1, 'tile count after add'); - cache.cacheTile({ - tile: fakeTile1, + tile1._caches[tile1.cacheKey] = cache.cacheTile({ + tile: tile1, tiledImage: fakeTiledImage0 }); + tile1._cacheSize++; assert.equal(cache.numTilesLoaded(), 1, 'tile count after add of second image'); - cache.cacheTile({ - tile: fakeTile2, + tile2._caches[tile2.cacheKey] = cache.cacheTile({ + tile: tile2, tiledImage: fakeTiledImage0 }); + tile2._cacheSize++; assert.equal(cache.numTilesLoaded(), 2, 'tile count after additional same image'); done(); }); + //Tile API and cache interaction + QUnit.test('Tile API: basic conversion', function(test) { + const done = test.async(); + const fakeViewer = { + raiseEvent: function() {} + }; + const tileCache = new OpenSeadragon.TileCache(); + const fakeTiledImage0 = { + viewer: fakeViewer, + source: OpenSeadragon.TileSource.prototype, + _tileCache: tileCache + }; + const fakeTiledImage1 = { + viewer: fakeViewer, + source: OpenSeadragon.TileSource.prototype, + _tileCache: tileCache + }; + + //load data + const tile00 = createFakeTile('foo.jpg', fakeTiledImage0); + tile00.setCache(tile00.cacheKey, 0, T_A, false); + const tile01 = createFakeTile('foo2.jpg', fakeTiledImage0); + tile01.setCache(tile01.cacheKey, 0, T_B, false); + const tile10 = createFakeTile('foo3.jpg', fakeTiledImage1); + tile10.setCache(tile10.cacheKey, 0, T_C, false); + const tile11 = createFakeTile('foo3.jpg', fakeTiledImage1); + tile11.setCache(tile11.cacheKey, 0, T_C, false); + const tile12 = createFakeTile('foo.jpg', fakeTiledImage1); + tile12.setCache(tile12.cacheKey, 0, T_A, false); + + const collideGetSet = async (tile, type) => { + const value = await tile.getData(type, false); + await tile.setData(value, type, false); + return value; + }; + + //test set/get data in async env + (async function() { + test.equal(tileCache.numTilesLoaded(), 5, "We loaded 5 tiles"); + test.equal(tileCache.numCachesLoaded(), 3, "We loaded 3 cache objects"); + + //test structure + const c00 = tile00.getCache(tile00.cacheKey); + test.equal(c00.getTileCount(), 2, "Two tiles share key = url = foo.jpg."); + const c01 = tile01.getCache(tile01.cacheKey); + test.equal(c01.getTileCount(), 1, "No tiles share key = url = foo2.jpg."); + const c10 = tile10.getCache(tile10.cacheKey); + test.equal(c10.getTileCount(), 2, "Two tiles share key = url = foo3.jpg."); + const c12 = tile12.getCache(tile12.cacheKey); + + //test get/set data A + let value = await tile00.getData(undefined, false); + test.equal(typeAtoB, 0, "No conversion happened when requesting default type data."); + test.equal(value, 0, "No conversion, no increase in value A."); + //explicit type + value = await tile00.getData(T_A, false); + test.equal(typeAtoB, 0, "No conversion also for tile sharing the cache."); + test.equal(value, 0, "Again, no increase in value A."); + + //copy & set type A + value = await tile00.getData(T_A, true); + test.equal(typeAtoB, 0, "No conversion also for tile sharing the cache."); + test.equal(copyA, 1, "A copy happened."); + test.equal(value, 1, "+1 conversion step happened."); + await tile00.setData(value, T_A, false); //overwrite + test.equal(tile00.cacheKey, tile00.originalCacheKey, "Overwriting cache: no change in value."); + test.equal(c00.type, T_A, "The tile cache data type was unchanged."); + //convert to B, async + sync behavior + value = await tile00.getData(T_B, false); + await tile00.setData(value, T_B, false); //overwrite + test.equal(typeAtoB, 1, "Conversion A->B happened."); + test.equal(value, 2, "+1 conversion step happened."); + //shares cache with tile12 (overwrite=false) + value = await tile12.getData(T_B, false); + test.equal(typeAtoB, 1, "Conversion A->B happened only once."); + test.equal(value, 2, "Value did not change."); + + //test ASYNC get data + value = await tile12.getData(T_B); + await tile12.setData(value, T_B, false); //overwrite + test.equal(typeAtoB, 1, "No conversion happened when requesting default type data."); + test.equal(typeBtoC, 0, "No conversion happened when requesting default type data."); + test.equal(copyB, 1, "B type copied."); + test.equal(value, 3, "Copy, increase in value type B."); + + // Async collisions testing + + //convert to A, before that request conversion to A and B several times, since we copy + // there should be just exactly the right amount of conversions + tile12.getData(T_A); // B -> C -> A + tile12.getData(T_B); // no conversion, all run at the same time + tile12.getData(T_B); // no conversion, all run at the same time + tile12.getData(T_A); // B -> C -> A + tile12.getData(T_B); // no conversion, all run at the same time + value = await tile12.getData(T_A); // B -> C -> A + test.equal(typeAtoB, 1, "No conversion A->B."); + test.equal(typeBtoC, 3, "Conversion B->C happened three times."); + test.equal(typeCtoA, 3, "Conversion C->A happened three times."); + test.equal(typeDtoA, 0, "Conversion D->A did not happen."); + test.equal(typeCtoE, 0, "Conversion C->E did not happen."); + test.equal(value, 5, "+2 conversion step happened, other conversion steps are copies discarded " + + "(get data does not modify cache)."); + + //but direct requests on cache change await + //convert to A, before that request conversion to A and B several times, should finish accordingly + c12.transformTo(T_A); // B -> C -> A + c12.transformTo(T_B); // A -> B second time + c12.transformTo(T_B); // no-op + c12.transformTo(T_A); // B -> C -> A + c12.transformTo(T_B); // A -> B third time + //should finish with next await with 6 steps at this point, add two more and await end + value = await c12.transformTo(T_A); // B -> C -> A + test.equal(typeAtoB, 3, "Conversion A->B happened three times."); + test.equal(typeBtoC, 6, "Conversion B->C happened six times."); + test.equal(typeCtoA, 6, "Conversion C->A happened six times."); + test.equal(typeDtoA, 0, "Conversion D->A did not happen."); + test.equal(typeCtoE, 0, "Conversion C->E did not happen."); + test.equal(value, 11, "5-2+8 conversion step happened (the test above did not save the cache so 3 is value)."); + await tile12.setData(value, T_B, false); // B -> C -> A + + // Get set collide tries to modify the cache + collideGetSet(tile12, T_A); // B -> C -> A + collideGetSet(tile12, T_B); // no conversion, all run at the same time + collideGetSet(tile12, T_B); // no conversion, all run at the same time + collideGetSet(tile12, T_A); // B -> C -> A + collideGetSet(tile12, T_B); // no conversion, all run at the same time + //should finish with next await with 6 steps at this point, add two more and await end + value = await collideGetSet(tile12, T_A); // B -> C -> A + test.equal(typeAtoB, 3, "Conversion A->B not increased, not needed as all T_B requests resolve immediatelly."); + test.equal(typeBtoC, 9, "Conversion B->C happened three times more."); + test.equal(typeCtoA, 9, "Conversion C->A happened three times more."); + test.equal(typeDtoA, 0, "Conversion D->A did not happen."); + test.equal(typeCtoE, 0, "Conversion C->E did not happen."); + test.equal(value, 13, "11+2 steps (writes are colliding, just single write will happen)."); + + //shares cache with tile12 + value = await tile00.getData(T_A, false); + test.equal(typeAtoB, 3, "Conversion A->B nor triggered."); + test.equal(value, 13, "Value did not change."); + + //now set value with keeping origin + await tile00.setData(42, T_D, true); + test.equal(tile12.originalCacheKey, tile12.cacheKey, "Related tile not affected."); + test.equal(tile00.originalCacheKey, tile12.originalCacheKey, "Cache data was modified, original kept."); + test.notEqual(tile00.cacheKey, tile12.cacheKey, "Main cache keys changed."); + const newCache = tile00.getCache(); + await newCache.transformTo(T_C); + test.equal(typeDtoA, 1, "Conversion D->A happens first time."); + test.equal(c12.data, 13, "Original cache value kept"); + test.equal(c12.type, T_A, "Original cache type kept"); + test.equal(c12, c00, "The same cache."); + + test.equal(typeAtoB, 4, "Conversion A->B triggered."); + test.equal(newCache.type, T_C, "Original cache type kept"); + test.equal(newCache.data, 45, "42+3 steps happened."); + + //try again change in set data, now the cache gets overwritten + await tile00.setData(42, T_B, true); + test.equal(newCache.type, T_B, "Reset happened in place."); + test.equal(newCache.data, 42, "Reset happened in place."); + + // Overwriting stress test with diff cache (see the same test as above, the same reasoning) + collideGetSet(tile00, T_A); // B -> C -> A + collideGetSet(tile00, T_B); // no conversion, all run at the same time + collideGetSet(tile00, T_B); // no conversion, all run at the same time + collideGetSet(tile00, T_A); // B -> C -> A + collideGetSet(tile00, T_B); // no conversion, all run at the same time + //should finish with next await with 6 steps at this point, add two more and await end + value = await collideGetSet(tile00, T_A); // B -> C -> A + test.equal(typeAtoB, 4, "Conversion A->B not increased."); + test.equal(typeBtoC, 13, "Conversion B->C happened three times more."); + //we converted D->C before, that's why C->A is one less + test.equal(typeCtoA, 12, "Conversion C->A happened three times more."); + test.equal(typeDtoA, 1, "Conversion D->A did not happen."); + test.equal(typeCtoE, 0, "Conversion C->E did not happen."); + test.equal(value, 44, "+2 writes value (writes collide, just one finishes last)."); + + test.equal(c12.data, 13, "Original cache value kept"); + test.equal(c12.type, T_A, "Original cache type kept"); + test.equal(c12, c00, "The same cache."); + + //todo test destruction throughout the test above + //tile00.unload(); + + done(); + })(); + }); + + //Tile API and cache interaction + QUnit.test('Tile API Cache Interaction', function(test) { + const done = test.async(); + const fakeViewer = { + raiseEvent: function() {} + }; + const tileCache = new OpenSeadragon.TileCache(); + const fakeTiledImage0 = { + viewer: fakeViewer, + source: OpenSeadragon.TileSource.prototype, + _tileCache: tileCache + }; + const fakeTiledImage1 = { + viewer: fakeViewer, + source: OpenSeadragon.TileSource.prototype, + _tileCache: tileCache + }; + + //load data + const tile00 = createFakeTile('foo.jpg', fakeTiledImage0); + tile00.setCache(tile00.cacheKey, 0, T_A, false); + const tile01 = createFakeTile('foo2.jpg', fakeTiledImage0); + tile01.setCache(tile01.cacheKey, 0, T_B, false); + const tile10 = createFakeTile('foo3.jpg', fakeTiledImage1); + tile10.setCache(tile10.cacheKey, 0, T_C, false); + const tile11 = createFakeTile('foo3.jpg', fakeTiledImage1); + tile11.setCache(tile11.cacheKey, 0, T_C, false); + const tile12 = createFakeTile('foo.jpg', fakeTiledImage1); + tile12.setCache(tile12.cacheKey, 0, T_A, false); + + //test set/get data in async env + (async function() { + test.equal(tileCache.numTilesLoaded(), 5, "We loaded 5 tiles"); + test.equal(tileCache.numCachesLoaded(), 3, "We loaded 3 cache objects"); + + const c00 = tile00.getCache(tile00.cacheKey); + const c12 = tile12.getCache(tile12.cacheKey); + + //now test multi-cache within tile + const theTileKey = tile00.cacheKey; + tile00.setData(42, T_E, true); + test.ok(tile00.cacheKey !== tile00.originalCacheKey, "Original cache key differs."); + test.equal(theTileKey, tile00.originalCacheKey, "Original cache key preserved."); + + //now add artifically another record + tile00.setCache("my_custom_cache", 128, T_C); + test.equal(tileCache.numTilesLoaded(), 5, "We still loaded only 5 tiles."); + test.equal(tileCache.numCachesLoaded(), 5, "The cache has now 5 items."); + test.equal(c00.getTileCount(), 2, "The cache still has only two tiles attached."); + test.equal(tile00.getCacheSize(), 3, "The tile has three cache objects."); + //related tile not really affected + test.equal(tile12.cacheKey, tile12.originalCacheKey, "Original cache key not affected elsewhere."); + test.equal(tile12.originalCacheKey, theTileKey, "Original cache key also preserved."); + test.equal(c12.getTileCount(), 2, "The original data cache still has only two tiles attached."); + test.equal(tile12.getCacheSize(), 1, "Related tile cache did not increase."); + + //add and delete cache nothing changes + tile00.setCache("my_custom_cache2", 128, T_C); + tile00.unsetCache("my_custom_cache2"); + test.equal(tileCache.numTilesLoaded(), 5, "We still loaded only 5 tiles."); + test.equal(tileCache.numCachesLoaded(), 5, "The cache has now 5 items."); + test.equal(tile00.getCacheSize(), 3, "The tile has three cache objects."); + + //delete cache as a zombie + tile00.setCache("my_custom_cache2", 17, T_C); + //direct access shoes correct value although we set key! + const myCustomCache2Data = tile00.getCache("my_custom_cache2").data; + test.equal(myCustomCache2Data, 17, "Previously defined cache does not intervene."); + test.equal(tileCache.numCachesLoaded(), 6, "The cache size is 6."); + //keep zombie + tile00.unsetCache("my_custom_cache2", false); + test.equal(tileCache.numCachesLoaded(), 6, "The cache is 5 + 1 zombie, no change."); + test.equal(tile00.getCacheSize(), 3, "The tile has three cache objects."); + + //revive zombie + tile01.setCache("my_custom_cache2", 18, T_C); + const myCustomCache2OtherData = tile01.getCache("my_custom_cache2").data; + test.equal(myCustomCache2OtherData, myCustomCache2Data, "Caches are equal because revived."); + //again, keep zombie + tile01.unsetCache("my_custom_cache2", false); + + //first create additional cache so zombie is not the youngest + tile01.setCache("some weird cache", 11, T_A); + test.ok(tile01.cacheKey === tile01.originalCacheKey, "Custom cache does not touch tile cache keys."); + + //insertion aadditional cache clears the zombie first although it is not the youngest one + test.equal(tileCache.numCachesLoaded(), 7, "The cache has now 7 items."); + + //Test CAP + tileCache._maxCacheItemCount = 7; + + //does not trigger insertion - deletion, since we setData to cache that already exists, 43 value ignored + tile12.setData(43, T_B, true); + test.notEqual(tile12.cacheKey, tile12.originalCacheKey, "Original cache key differs."); + test.equal(theTileKey, tile12.originalCacheKey, "Original cache key preserved."); + test.equal(tileCache.numCachesLoaded(), 7, "The cache has still 7 items."); + //we called SET DATA with preserve=true on tile12 which was sharing cache with tile00, new cache is also shared + test.equal(tile00.originalCacheKey, tile12.originalCacheKey, "Original cache key matches between tiles."); + test.equal(tile00.cacheKey, tile12.cacheKey, "Modified cache key matches between tiles."); + test.equal(tile12.getCache().data, 42, "The value is not 43 as setData triggers cache share!"); + + //triggers insertion - deletion of zombie cache 'my_custom_cache2' + tile00.setCache("trigger-max-cache-handler", 5, T_C); + //reset CAP + tileCache._maxCacheItemCount = OpenSeadragon.DEFAULT_SETTINGS.maxImageCacheCount; + + //try to revive zombie will fail: the zombie was deleted, we will find 18 + tile01.setCache("my_custom_cache2", 18, T_C); + const myCustomCache2RecreatedData = tile01.getCache("my_custom_cache2").data; + test.notEqual(myCustomCache2RecreatedData, myCustomCache2Data, "Caches are not equal because created."); + test.equal(myCustomCache2RecreatedData, 18, "Cache data is actually as set to 18."); + test.equal(tileCache.numCachesLoaded(), 8, "The cache has now 8 items."); + + + //delete cache bound to other tiles, this tile has 4 caches: + // cacheKey: shared, originalCacheKey: shared, , + // note that cacheKey is shared because we called setData on two items that both create MOD cache + tileCache.unloadTile(tile00, true, tileCache._tilesLoaded.indexOf(tile00)); + test.equal(tileCache.numCachesLoaded(), 6, "The cache has now 8-2 items."); + test.equal(tileCache.numTilesLoaded(), 4, "One tile removed."); + test.equal(c00.getTileCount(), 1, "The cache has still tile12 left."); + + //now test tile destruction as zombie + + //now test tile cache sharing + done(); + })(); + }); + QUnit.test('Zombie Cache', function(test) { const done = test.async(); diff --git a/test/modules/typeConversion.js b/test/modules/type-conversion.js similarity index 59% rename from test/modules/typeConversion.js rename to test/modules/type-conversion.js index fb48731c..36787243 100644 --- a/test/modules/typeConversion.js +++ b/test/modules/type-conversion.js @@ -27,6 +27,7 @@ // Replace conversion with our own system and test: __TEST__ prefix must be used, otherwise // other tests will interfere + // Note: this is not the same as in the production conversion, where CANVAS on its own does not exist 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 @@ -81,6 +82,7 @@ }); + QUnit.module('TypeConversion', { beforeEach: function () { $('
').appendTo("#qunit-fixture"); @@ -113,7 +115,7 @@ test.ok(Convertor.getConversionPath("__TEST__url", "__TEST__image"), "Type conversion ok between TEST types."); - test.ok(Convertor.getConversionPath("canvas", "context2d"), + test.ok(Convertor.getConversionPath("url", "context2d"), "Type conversion ok between real types."); test.equal(Convertor.getConversionPath("url", "__TEST__image"), undefined, @@ -124,15 +126,61 @@ done(); }); + QUnit.test('Copy of build-in types', function (test) { + const done = test.async(); + + //prepare data + const URL = "/test/data/A.png"; + const image = new Image(); + image.onerror = image.onabort = () => { + test.ok(false, "Image data preparation failed to load!"); + done(); + }; + const canvas = document.createElement( 'canvas' ); + //test when ready + image.onload = async () => { + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext('2d'); + context.drawImage( image, 0, 0 ); + + //copy URL + const URL2 = await Convertor.copy(URL, "url"); + //we cannot check if they are not the same object, strings are immutable (and we don't copy anyway :D ) + test.equal(URL, URL2, "String copy is equal in data."); + test.equal(typeof URL, typeof URL2, "Type of copies equals."); + test.equal(URL.length, URL2.length, "Data length is also equal."); + + //copy context + const context2 = await Convertor.copy(context, "context2d"); + test.notEqual(context, context2, "Copy is not the same as original canvas."); + test.equal(typeof context, typeof context2, "Type of copies equals."); + test.equal(context.canvas.toDataURL(), context2.canvas.toDataURL(), "Data is equal."); + + //copy image + const image2 = await Convertor.copy(image, "image"); + test.notEqual(image, image2, "Copy is not the same as original image."); + test.equal(typeof image, typeof image2, "Type of copies equals."); + test.equal(image.src, image2.src, "Data is equal."); + + done(); + }; + image.src = URL; + }); + // ---------- - QUnit.test('Manual Data Convertors: testing conversion & destruction', function (test) { + QUnit.test('Manual Data Convertors: testing conversion, copies & 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, 0, "Url destructor not called automatically."); + Convertor.destroy("/test/data/A.png", "__TEST__url"); 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 @@ -140,7 +188,7 @@ 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."); + test.equal(imageDestroy, 0, "Image destructor not called unless we ask it."); 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."); @@ -152,8 +200,8 @@ 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(imageDestroy, 0, "Image destructor not called."); + test.equal(canvasDestroy, 0, "Canvas destructor called."); test.equal(contex2DDestroy, 0, "Image destructor not called."); done(); }); @@ -170,19 +218,19 @@ cache.addTile(dummyTile, "/test/data/A.png", "__TEST__url"); //load image object: url -> image - cache.getData("__TEST__image").then(_ => { + cache.transformTo("__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"); + return cache.transformTo("__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"); + return cache.transformTo("__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."); @@ -208,15 +256,113 @@ }); }); + QUnit.test('Data Convertors via Cache object: testing set/get', 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.testGetSet = async function(type) { + const value = await cache.getDataAs(type, false); + await cache.setDataAs(value, type); + return value; + } + cache.addTile(dummyTile, "/test/data/A.png", "__TEST__url"); + + //load image object: url -> image + cache.testGetSet("__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.testGetSet("__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.testGetSet("__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 after a copy was requested but not yet processed.', function (test) { + const done = test.async(); + + let conversionHappened = false; + Convertor.learn("__TEST__url", "__TEST__longConversionProcessForTesting", value => { + return new Promise((resolve, reject) => { + setTimeout(() => { + conversionHappened = true; + resolve("modified " + value); + }, 20); + }); + }, 1, 1); + let longConversionDestroy = 0; + Convertor.learnDestroy("__TEST__longConversionProcessForTesting", _ => { + longConversionDestroy++; + }); + + 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.getDataAs("__TEST__longConversionProcessForTesting").then(convertedData => { + test.equal(longConversionDestroy, 0, "Copy not destroyed."); + test.notOk(cache.loaded, "Cache was destroyed."); + test.equal(cache.data, undefined, "Already destroyed cache does not return data."); + test.equal(urlDestroy, 1, "Url was destroyed."); + test.notOk(conversionHappened, "Nothing happened since before us the cache was deleted."); + + //destruction will likely happen after we finish current async callback + setTimeout(async () => { + test.notOk(conversionHappened, "Still no conversion."); + done(); + }, 25); + }); + test.ok(cache.loaded, "Cache is still not loaded."); + test.equal(cache.data, "/test/data/A.png", "Get data does not override cache."); + test.equal(cache.type, "__TEST__url", "Cache did not change its type."); + cache.destroy(); + test.notOk(cache.type, "Type erased immediatelly as the data copy is out."); + test.equal(urlDestroy, 1, "We destroyed cache before copy conversion finished."); + }); + 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", _ => { + Convertor.learn("__TEST__url", "__TEST__longConversionProcessForTesting", value => { return new Promise((resolve, reject) => { setTimeout(() => { conversionHappened = true; - resolve("some interesting data"); + resolve("modified " + value); }, 20); }); }, 1, 1); @@ -231,10 +377,10 @@ const cache = new OpenSeadragon.CacheRecord(); cache.addTile(dummyTile, "/test/data/A.png", "__TEST__url"); - cache.getData("__TEST__longConversionProcessForTesting").then(_ => { + cache.transformTo("__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.data, "modified /test/data/A.png", "We got the correct data."); test.equal(cache.type, "__TEST__longConversionProcessForTesting", "Cache declares new type."); test.equal(urlDestroy, 1, "Url was destroyed."); @@ -253,6 +399,7 @@ 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 diff --git a/test/test.html b/test/test.html index 14889351..9baa6c1f 100644 --- a/test/test.html +++ b/test/test.html @@ -33,7 +33,7 @@ - +