diff --git a/docs/source/animator.rst b/docs/source/animator.rst index 5639fe4..cc98622 100644 --- a/docs/source/animator.rst +++ b/docs/source/animator.rst @@ -418,6 +418,29 @@ ANIMATOR_CONTOUR_MATCH See the `source code `__. +This test verifies that a spectrally matched image is animated alongside the reference image: it +steps through its own matched channels, returns contours and histograms at every step, and is tiled +only while the frontend is actually rendering it. + +The tiles of a matched image are controlled entirely by **ADD_REQUIRED_TILES** sent *during* the +animation. Outside an animation that message returns tile data immediately and does not become an +animation setting, so a matched image opened before **START_ANIMATION** is not tiled until the +frontend sends a tile request for it while the animation runs. Sending one with an empty tile list — +which is what the frontend does when a matched image is no longer visible — stops its tiles again +without stopping its channel updates. + +.. note:: + + The backend runs at most one animation frame ahead of the last flow-control acknowledgement, and + a message sent mid-frame takes effect a frame or two later. Each phase below is therefore checked + over the three channels before the next client message, leaving the channels around each message + unchecked. + + The channel on which a message is sent is itself unchecked. Within one frame the backend serves + the reference image and then each matched image, and the frontend sends the message as soon as the + reference image tile arrives, so the message can still take effect on the matched image of that + same frame. The channel before it is the last one whose matched image has already been served. + 1. Frontend sends: **OPEN_FILE** (``OpenFile``) for two files File 1: @@ -442,11 +465,11 @@ See the `source code void }[] = []; + + // Resolves once the active file has delivered the tile of the given channel. + const reached = (channel: number) => + new Promise((resolve) => { + if (lastActiveChannel >= channel) { + resolve(); + } else { + waiters.push({ channel, resolve }); + } + }); + + const settle = () => { + waiters = waiters.filter((waiter) => { + if (lastActiveChannel >= waiter.channel) { + waiter.resolve(); + return false; + } + return true; + }); + }; + + const subscriptions: Subscription[] = [ + msgController.rasterSyncStream.subscribe((data) => record.sync.push(data)), + msgController.rasterTileStream.subscribe((data) => { + record.tile.push(data); + if (data.fileId === activeFileId) { + lastActiveChannel = data.channel; + msgController.sendAnimationFlowControl({ + fileId: activeFileId, + animationId: 0, + receivedFrame: { channel: data.channel, stokes: data.stokes }, + timestamp: Long.fromNumber(Date.now()), + }); + settle(); + } + }), + msgController.contourStream.subscribe((data) => record.contour.push(data)), + msgController.histogramStream.subscribe((data) => record.histogram.push(data)), + ]; + + // Ends the collection once the playback is over. + const stop = () => subscriptions.forEach((subscription) => subscription.unsubscribe()); + + return { record, reached, stop }; +} + let basepath: string; -describe('ANIMATOR_CONTOUR: Testing animation playback with contour lines', () => { +describe('ANIMATOR_CONTOUR_MATCH: Testing animation playback of a spectrally matched image', () => { const msgController = MessageController.Instance; describe(`Register a session`, () => { beforeAll(async () => { @@ -172,149 +298,195 @@ describe('ANIMATOR_CONTOUR: Testing animation playback with contour lines', () = }); describe(`Preparation`, () => { - test(`Contour set`, async () => { - msgController.addRequiredTiles(assertItem.addTilesReq); - let RasterTileDataResponse = await Stream( - CARTA.RasterTileData, - assertItem.addTilesReq.tiles.length + 2 - ); - - msgController.setContourParameters(assertItem.setContour[0]); - let ContourImageDataResponse1 = await Stream( - CARTA.ContourImageData, - assertItem.setContour[0].levels.length - ); + test( + `Render both images and set matched contours`, + async () => { + for (let i = 0; i < assertItem.initTilesReq.length; i++) { + let rasterResponse = Stream(CARTA.RasterTileData, assertItem.initTilesReq[i].tiles!.length + 2); + msgController.addRequiredTiles(assertItem.initTilesReq[i]); + await rasterResponse; + } - msgController.setContourParameters(assertItem.setContour[1]); - let ContourImageDataResponse2 = await Stream( - CARTA.ContourImageData, - assertItem.setContour[1].levels.length - ); - }); + for (let i = 0; i < assertItem.setContour.length; i++) { + let contourResponse = Stream(CARTA.ContourImageData, assertItem.setContour[i].levels!.length); + msgController.setContourParameters(assertItem.setContour[i]); + await contourResponse; + } + }, + readFileTimeout * 2 + ); }); - describe(`Play some channels forwardly`, () => { - let regionHistogramData: CARTA.RegionHistogramData[] = []; - let sequence: number[] = []; - let contourImageData: CARTA.ContourImageData[] = []; - let HistogramSequence: number[] = []; - let ContourSequence: number[] = []; - test(`Assert ContourImageData.channel = RasterTileData.channel`, async () => { - let StartAnimationResponse = await msgController.startAnimation(assertItem.startAnimation); - expect(StartAnimationResponse.success).toEqual(true); - - for (let i = 0; i < assertItem.stopAnimation.endFrame.channel; i++) { - msgController.addRequiredTiles(assertItem.addTilesReq); - let resRegionHistogramData = msgController.histogramStream.pipe(take(2)).subscribe({ - next: (data) => { - regionHistogramData.push(data); - HistogramSequence.push(data.channel); - }, - }); - let rasterTileDataResponse = await Stream(CARTA.RasterTileData, 3); - let resContourImageData = msgController.contourStream.pipe(take(4)).subscribe({ - next: (data) => { - contourImageData.push(data); - ContourSequence.push(data.channel); - }, - }); - let currentChannel = rasterTileDataResponse[0].channel; - sequence.push(currentChannel); - msgController.sendAnimationFlowControl({ - fileId: 0, - animationId: 0, - receivedFrame: { - channel: currentChannel, - stokes: 0, - }, - timestamp: Long.fromNumber(Date.now()), - }); - } - - // // Pick up the streaming messages - // // Channel 11 & 12: RasterTileData + RasterTileSync(start & end) + RegionHistogramData - // let RegionHistogramDataChannel11: CARTA.RegionHistogramData[] = []; - // msgController.histogramStream.pipe(take(1)).subscribe(data => { - // RegionHistogramDataChannel11.push(data) - // }); - // let ContourImageDataChannel11 = await Stream(CARTA.ContourImageData,4) - // console.log(ContourImageDataChannel11); - // let RasterTileDataChannel11 = await Stream(CARTA.RasterTileData,3); - // console.log(RasterTileDataChannel11); - - // let RegionHistogramDataChannel12: CARTA.RegionHistogramData[] = []; - // msgController.histogramStream.pipe(take(1)).subscribe(data => { - // RegionHistogramDataChannel12.push(data) - // }); - // let ContourImageDataChannel12 = await Stream(CARTA.ContourImageData,4) - // console.log(ContourImageDataChannel12); - // let RasterTileDataChannel12 = await Stream(CARTA.RasterTileData,3); - // console.log(RasterTileDataChannel12) - }); + describe(`Play the animation of the reference image`, () => { + let record: AnimationRecord; - test(`Assert the last channel = StopAnimation.endFrame`, async () => { - msgController.stopAnimation(assertItem.stopAnimation); - msgController.setChannels(assertItem.setImageChannel[0]); - let lastRegionHistogramData1: CARTA.RegionHistogramData[] = []; - msgController.histogramStream.pipe(take(1)).subscribe({ - next: (data) => { - lastRegionHistogramData1.push(data); - }, + test( + `Play up to channel ${assertItem.milestone.stop}, hiding and restoring the matched image`, + async () => { + const collector = collectAnimation(assertItem.startAnimation.fileId!); + record = collector.record; + + let StartAnimationResponse = await msgController.startAnimation(assertItem.startAnimation); + expect(StartAnimationResponse.success).toEqual(true); + + // The matched image has no animation view settings yet, so it is not tiled. + await collector.reached(assertItem.milestone.requestMatchedTiles); + msgController.addRequiredTiles(assertItem.matchedTilesReq); + + // The matched image goes out of view: an empty tile list stops its tiles. + await collector.reached(assertItem.milestone.hideMatchedImage); + msgController.addRequiredTiles(assertItem.hiddenTilesReq); + + // The matched image comes back into view. + await collector.reached(assertItem.milestone.restoreMatchedImage); + msgController.addRequiredTiles(assertItem.matchedTilesReq); + + // The reference image's tile of the stop channel implies every message of the + // last asserted frame is already out, but leave a margin before unsubscribing. + await collector.reached(assertItem.milestone.stop); + await new Promise((resolve) => setTimeout(resolve, messageReturnTimeout)); + msgController.stopAnimation(assertItem.stopAnimation); + collector.stop(); + }, + playAnimatorTimeout + ); + + test(`START_ANIMATION_ACK.success = True and the reference channels are in sequence`, () => { + let channels = record.tile + .filter((data) => data.fileId === assertItem.startAnimation.fileId) + .map((data) => data.channel); + channels.map((channel, index) => { + expect(channel).toEqual(assertItem.startAnimation.startFrame!.channel! + index); }); - let lastContourImageData1: CARTA.ContourImageData[] = []; - msgController.contourStream.pipe(take(2)).subscribe({ - next: (data) => { - lastContourImageData1.push(data); - }, + expect(channels.length).toBeGreaterThanOrEqual(assertItem.milestone.stop); + }); + + describe(`Before ADD_REQUIRED_TILES of the matched image`, () => { + assertedChannels(assertItem.milestone.requestMatchedTiles).map((channel) => { + test(`Channel ${channel}: only the reference image is tiled`, () => { + expect( + record.tile.filter((data) => data.fileId === 0 && data.channel === channel).length + ).toEqual(1); + expect( + record.tile.filter((data) => data.fileId === 1 && data.channel === channel).length + ).toEqual(0); + expect( + record.sync.filter((data) => data.fileId === 1 && data.channel === channel).length + ).toEqual(0); + }); }); + }); - let lastRasterTileData1 = await Stream(CARTA.RasterTileData, 3); + describe(`After ADD_REQUIRED_TILES of the matched image`, () => { + assertedChannels(assertItem.milestone.hideMatchedImage).map((channel) => { + test(`Channel ${channel}: both images are tiled`, () => { + assertItem.fileOpen.map((file) => { + let tiles = record.tile.filter( + (data) => data.fileId === file.fileId && data.channel === channel + ); + expect(tiles.length).toEqual(1); + expect(tiles[0].stokes).toEqual(0); - msgController.setChannels(assertItem.setImageChannel[1]); - let lastRegionHistogramData2: CARTA.RegionHistogramData[] = []; - msgController.histogramStream.pipe(take(1)).subscribe({ - next: (data) => { - lastRegionHistogramData2.push(data); - }, + let syncs = record.sync.filter( + (data) => data.fileId === file.fileId && data.channel === channel + ); + expect(syncs.length).toEqual(2); + expect(syncs.filter((data) => data.endSync).length).toEqual(1); + expect(syncs.filter((data) => data.tileCount === 1).length).toEqual(2); + }); + }); }); - let lastContourImageData2: CARTA.ContourImageData[] = []; - msgController.contourStream.pipe(take(2)).subscribe({ - next: (data) => { - lastContourImageData2.push(data); - }, + }); + + describe(`After ADD_REQUIRED_TILES of the matched image with an empty tile list`, () => { + assertedChannels(assertItem.milestone.restoreMatchedImage).map((channel) => { + test(`Channel ${channel}: only the reference image is tiled`, () => { + expect( + record.tile.filter((data) => data.fileId === 0 && data.channel === channel).length + ).toEqual(1); + expect( + record.tile.filter((data) => data.fileId === 1 && data.channel === channel).length + ).toEqual(0); + expect( + record.sync.filter((data) => data.fileId === 1 && data.channel === channel).length + ).toEqual(0); + }); }); + }); - let lastRasterTileData2 = await Stream(CARTA.RasterTileData, 3); + describe(`After the matched image is restored`, () => { + assertedChannels(assertItem.milestone.stop).map((channel) => { + test(`Channel ${channel}: both images are tiled`, () => { + assertItem.fileOpen.map((file) => { + expect( + record.tile.filter((data) => data.fileId === file.fileId && data.channel === channel) + .length + ).toEqual(1); + expect( + record.sync.filter((data) => data.fileId === file.fileId && data.channel === channel) + .length + ).toEqual(2); + }); + }); + }); }); - test(`Received image channels should be in sequence`, async () => { - sequence.map((id, index) => { - let channelId = - index + - assertItem.startAnimation.startFrame.channel + - assertItem.startAnimation.deltaFrame.channel; - expect(id).toEqual(channelId - 1); + describe(`Contours and histograms of the matched image`, () => { + // The matched image keeps stepping through its own channels even while it is not + // tiled, so its contours and histograms arrive in every phase. + let allChannels = [ + ...assertedChannels(assertItem.milestone.requestMatchedTiles), + ...assertedChannels(assertItem.milestone.hideMatchedImage), + ...assertedChannels(assertItem.milestone.restoreMatchedImage), + ...assertedChannels(assertItem.milestone.stop), + ]; + + allChannels.map((channel) => { + test(`Channel ${channel}: both images return contours and a histogram`, () => { + assertItem.setContour.map((contour) => { + let contours = record.contour.filter( + (data) => + data.fileId === contour.fileId && data.channel === channel && data.progress === 1 + ); + expect(contours.length).toEqual(contour.levels!.length); + contours.map((data) => { + expect(data.referenceFileId).toEqual(contour.referenceFileId); + }); + + expect( + record.histogram.filter( + (data) => data.fileId === contour.fileId && data.channel === channel + ).length + ).toEqual(1); + }); + }); }); }); + }); + + describe(`Set the channel of each image after STOP_ANIMATION`, () => { + assertItem.setImageChannel.map((setImageChannel) => { + let rasterTileData: CARTA.RasterTileData[]; - test(`Assert a series of ContourImageData`, async () => { - for (let i = 2; i <= assertItem.stopAnimation.endFrame.channel; i++) { - let testSet = contourImageData.filter((data) => data.progress == 1 && data.channel == i); - expect(testSet.length).toEqual(assertItem.setContour[0].levels.length * assertItem.fileOpen.length); - expect(testSet.filter((data) => data.fileId == 0).length).toEqual( - assertItem.setContour[0].levels.length - ); - expect(testSet.filter((data) => data.fileId == 1).length).toEqual( - assertItem.setContour[1].levels.length - ); - } - expect(contourImageData.length).toEqual( - assertItem.stopAnimation.endFrame.channel * - assertItem.setContour[0].levels.length * - assertItem.fileOpen.length + test( + `File ${setImageChannel.fileId}: SET_IMAGE_CHANNELS returns RASTER_TILE_DATA`, + async () => { + let rasterResponse = Stream( + CARTA.RasterTileData, + setImageChannel.requiredTiles!.tiles!.length + 2 + ); + msgController.setChannels(setImageChannel); + rasterTileData = (await rasterResponse).filter( + (data: any) => data instanceof CARTA.RasterTileData + ); + }, + changeChannelTimeout ); - contourImageData.map((data) => { - expect(data.referenceFileId).toEqual(1); + + test(`File ${setImageChannel.fileId}: RASTER_TILE_DATA.channel = ${setImageChannel.channel}`, () => { + expect(rasterTileData.length).toEqual(1); + expect(rasterTileData[0].fileId).toEqual(setImageChannel.fileId); + expect(rasterTileData[0].channel).toEqual(setImageChannel.channel); }); }); });