diff --git a/frontend/javascripts/admin/api/mesh.ts b/frontend/javascripts/admin/api/mesh.ts index a1d77bdad5d..72f2e8e8036 100644 --- a/frontend/javascripts/admin/api/mesh.ts +++ b/frontend/javascripts/admin/api/mesh.ts @@ -15,7 +15,7 @@ export type MeshLodInfo = { transform: [Vector4, Vector4, Vector4]; // 4x3 matrix }; -type MeshSegmentInfo = { +export type MeshSegmentInfo = { meshFormat: "draco"; lods: Array; chunkScale: Vector3; @@ -24,6 +24,7 @@ type MeshSegmentInfo = { type ListMeshChunksRequest = { meshFileName: string; segmentId: number; + editableMappingVersion: number | undefined | null; }; export function getMeshfileChunksForSegment( @@ -41,6 +42,7 @@ export function getMeshfileChunksForSegment( // editableMappingTracingId should be the tracing id, not the editable mapping id. // If this is set, it is assumed that the request is about an editable mapping. editableMappingTracingId: string | null | undefined, + editableMappingVersion: number | undefined | null, // TODO: add to callee ): Promise { return doWithToken((token) => { const params = new URLSearchParams(); @@ -54,6 +56,7 @@ export function getMeshfileChunksForSegment( const payload: ListMeshChunksRequest = { meshFileName: meshFile.name, segmentId, + editableMappingVersion, }; return Request.sendJSONReceiveJSON( `${dataStoreUrl}/data/datasets/${datasetId}/layers/${layerName}/meshes/chunks?${params}`, @@ -71,7 +74,7 @@ type MeshChunkDataRequest = { segmentId: number | null; // Only relevant for neuroglancer precomputed meshes }; -type MeshChunkDataRequestList = { +export type MeshChunkDataRequestList = { meshFileName: string; requests: MeshChunkDataRequest[]; }; diff --git a/frontend/javascripts/admin/rest_api.ts b/frontend/javascripts/admin/rest_api.ts index 6da099b3bf7..c09278eb0b3 100644 --- a/frontend/javascripts/admin/rest_api.ts +++ b/frontend/javascripts/admin/rest_api.ts @@ -1925,6 +1925,7 @@ type MeshRequest = { mappingName: string | null | undefined; mappingType: MappingType | null | undefined; findNeighbors: boolean; + version: number; }; export function computeAdHocMesh( diff --git a/frontend/javascripts/libs/diffable_map.ts b/frontend/javascripts/libs/diffable_map.ts index 27bafd3c0aa..1d46051e6cc 100644 --- a/frontend/javascripts/libs/diffable_map.ts +++ b/frontend/javascripts/libs/diffable_map.ts @@ -1,3 +1,5 @@ +import _ from "lodash"; + const defaultItemsPerBatch = 1000; let idCounter = 0; const idSymbol = Symbol("id"); @@ -417,9 +419,11 @@ class DiffableMap implements NotEnumerableByObject { ? "null" : v === undefined ? "undefined" - : typeof v === "object" - ? JSON.stringify(v) - : String(v); + : v instanceof DiffableMap + ? v.toString() + : typeof v === "object" + ? JSON.stringify(v) + : String(v); return `${k} => ${vStr}`; }) .join(", "); @@ -468,11 +472,15 @@ function shallowCopy(template: DiffableMap): Diffable export function diffDiffableMaps( mapA: DiffableMap, mapB: DiffableMap, + useDeepEqualityCheck: boolean = false, ): { changed: Array; onlyA: Array; onlyB: Array; } { + const areDifferent = (valueA: V | undefined, valueB: V | undefined) => + useDeepEqualityCheck ? !_.isEqual(valueA, valueB) : valueA !== valueB; + // For the edge case that one of the maps is empty, we will consider them dependent, anyway const areDiffsDependent = mapA.getId() === mapB.getId() || mapA.size() === 0 || mapB.size() === 0; let idx = 0; @@ -506,7 +514,7 @@ export function diffDiffableMaps( for (const key of setA.values()) { if (setB.has(key)) { - if (currentMapA.get(key) !== currentMapB.get(key)) { + if (areDifferent(currentMapA.get(key), currentMapB.get(key))) { changed.push(key); } else { // The key exists in both chunks, do not emit this key. @@ -530,32 +538,32 @@ export function diffDiffableMaps( idx++; } - if (!areDiffsDependent) { - // Since, the DiffableMaps don't share the same structure, we might have - // aggregated false-positives, meaning onlyA and onlyB can include the same - // keys, which might or might not belong to the changed set. - // Construct a set for fast lookup - const setA = new Set(onlyA); - // Intersection of onlyA and onlyB: - const missingChangedIds = onlyB.filter((id) => setA.has(id)); - const missingChangedIdSet = new Set(missingChangedIds); - const newOnlyA = onlyA.filter((id) => !missingChangedIdSet.has(id)); - const newOnlyB = onlyB.filter((id) => !missingChangedIdSet.has(id)); - // Ensure that these elements are not equal before adding them to "changed" - const newChanged = changed.concat( - missingChangedIds.filter((id) => mapA.getOrThrow(id) !== mapB.getOrThrow(id)), - ); + if (areDiffsDependent) { return { - changed: newChanged, - onlyA: newOnlyA, - onlyB: newOnlyB, + changed, + onlyA, + onlyB, }; } + // Since, the DiffableMaps don't share the same structure, we might have + // aggregated false-positives, meaning onlyA and onlyB can include the same + // keys, which might or might not belong to the changed set. + // Construct a set for fast lookup + const setA = new Set(onlyA); + // Intersection of onlyA and onlyB: + const missingChangedIds = onlyB.filter((id) => setA.has(id)); + const missingChangedIdSet = new Set(missingChangedIds); + const newOnlyA = onlyA.filter((id) => !missingChangedIdSet.has(id)); + const newOnlyB = onlyB.filter((id) => !missingChangedIdSet.has(id)); + // Ensure that these elements are not equal before adding them to "changed" + const newChanged = changed.concat( + missingChangedIds.filter((id) => areDifferent(mapA.getOrThrow(id), mapB.getOrThrow(id))), + ); return { - changed, - onlyA, - onlyB, + changed: newChanged, + onlyA: newOnlyA, + onlyB: newOnlyB, }; } diff --git a/frontend/javascripts/libs/utils.ts b/frontend/javascripts/libs/utils.ts index 8a54888d50f..3d938ab8515 100644 --- a/frontend/javascripts/libs/utils.ts +++ b/frontend/javascripts/libs/utils.ts @@ -621,6 +621,46 @@ export function diffArrays( }; } +export function diffNumberArrays(a: number[], b: number[]) { + // Create sorted copies to avoid mutating inputs + const A = [...a].sort((x, y) => x - y); + const B = [...b].sort((x, y) => x - y); + + const both: number[] = []; + const onlyA: number[] = []; + const onlyB: number[] = []; + + let indexA = 0; + let indexB = 0; + + while (indexA < A.length && indexB < B.length) { + if (A[indexA] === B[indexB]) { + both.push(A[indexA]); + indexA++; + indexB++; + } else if (A[indexA] < B[indexB]) { + onlyA.push(A[indexA]); + indexA++; + } else { + onlyB.push(B[indexB]); + indexB++; + } + } + + // Remaining elements + while (indexA < A.length) { + onlyA.push(A[indexA]); + indexA++; + } + + while (indexB < B.length) { + onlyB.push(B[indexB]); + indexB++; + } + + return { both, onlyA, onlyB }; +} + export function diffMaps( stateA: Map, stateB: Map, diff --git a/frontend/javascripts/test/backend-snapshot-tests/annotations.e2e.ts b/frontend/javascripts/test/backend-snapshot-tests/annotations.e2e.ts index 7d01dcd2dc7..8e02780a456 100644 --- a/frontend/javascripts/test/backend-snapshot-tests/annotations.e2e.ts +++ b/frontend/javascripts/test/backend-snapshot-tests/annotations.e2e.ts @@ -242,7 +242,9 @@ describe("Annotation API (E2E)", () => { ], }, ]; - const createTreesUpdateActions = Array.from(diffTrees(tracingId, new DiffableMap(), trees)); + const createTreesUpdateActions = Array.from( + diffTrees(tracingId, new DiffableMap(), trees, false), + ); const updateTreeGroupsUpdateAction = UpdateActions.updateTreeGroups(treeGroups, tracingId); const [saveQueue] = addVersionNumbers( createSaveQueueFromUpdateActions( @@ -266,7 +268,9 @@ describe("Annotation API (E2E)", () => { const createdExplorational = await api.createExplorational(datasetId, "skeleton", false, null); const { tracingId } = createdExplorational.annotationLayers[0]; let trees = createTreeMapFromTreeArray(generateDummyTrees(5, 6)); - const createTreesUpdateActions = Array.from(diffTrees(tracingId, new DiffableMap(), trees)); + const createTreesUpdateActions = Array.from( + diffTrees(tracingId, new DiffableMap(), trees, false), + ); const metadata = [ { key: "city", diff --git a/frontend/javascripts/test/fixtures/dataset_server_object.ts b/frontend/javascripts/test/fixtures/dataset_server_object.ts index 0c118d95939..6cb6b56df84 100644 --- a/frontend/javascripts/test/fixtures/dataset_server_object.ts +++ b/frontend/javascripts/test/fixtures/dataset_server_object.ts @@ -23,6 +23,7 @@ const sampleColorLayer: APIColorLayer = { }; export const sampleHdf5AgglomerateName = "sampleHdf5Mapping"; +export const sampleMappingFileName = "sampleMappingFile"; // this is a uint32 segmentation layer const sampleSegmentationLayer: APISegmentationLayer = { name: "segmentation", diff --git a/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts b/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts index e0b8657c7eb..92217164053 100644 --- a/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts +++ b/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts @@ -51,6 +51,7 @@ export const tracing: ServerVolumeTracing = { ], userStates: [], fallbackLayer: "segmentation", + hasSegmentIndex: true, }; export const annotation: APIAnnotation = { diff --git a/frontend/javascripts/test/helpers/agglomerate_mapping_helper.ts b/frontend/javascripts/test/helpers/agglomerate_mapping_helper.ts index bf961e7c1a4..200be53bc4c 100644 --- a/frontend/javascripts/test/helpers/agglomerate_mapping_helper.ts +++ b/frontend/javascripts/test/helpers/agglomerate_mapping_helper.ts @@ -153,6 +153,12 @@ export class AgglomerateMapping { return this.versions[version]; } + // Returns a copy of the current adjacency list. It is a deep clone to avoid direct manipulation from outside. + // AdjacencyList is currently unversioned. + getAdjacencyList(): Map> { + return _.cloneDeep(this.adjacencyList); + } + private resetVersionCounter(initialVersion: number) { /* * Reset the most recent version to be stored as version `initialVersion`. diff --git a/frontend/javascripts/test/helpers/apiHelpers.ts b/frontend/javascripts/test/helpers/apiHelpers.ts index 812ab66bab3..83347524cbc 100644 --- a/frontend/javascripts/test/helpers/apiHelpers.ts +++ b/frontend/javascripts/test/helpers/apiHelpers.ts @@ -1,6 +1,6 @@ import { type Mock, vi, type TestContext as BaseTestContext } from "vitest"; import _ from "lodash"; -import Constants, { ControlModeEnum, type Vector2 } from "viewer/constants"; +import Constants, { ControlModeEnum, type Vector3, type Vector2 } from "viewer/constants"; import { sleep } from "libs/utils"; import dummyUser from "test/fixtures/dummy_user"; import dummyOrga from "test/fixtures/dummy_organization"; @@ -20,7 +20,10 @@ import { annotation as VOLUME_ANNOTATION, annotationProto as VOLUME_ANNOTATION_PROTO, } from "../fixtures/volumetracing_server_objects"; -import DATASET, { sampleHdf5AgglomerateName } from "../fixtures/dataset_server_object"; +import DATASET, { + sampleHdf5AgglomerateName, + sampleMappingFileName, +} from "../fixtures/dataset_server_object"; import type { ApiInterface } from "viewer/api/api_latest"; import type { ModelType } from "viewer/model"; @@ -52,6 +55,7 @@ import { import { resetStoreAction, restartSagaAction, + sceneControllerInitializedAction, wkInitializedAction, } from "viewer/model/actions/actions"; import { setActiveUserAction } from "viewer/model/actions/user_actions"; @@ -73,10 +77,18 @@ import type { ServerTracing, ServerVolumeTracing, ElementClass, + APIMeshFileInfo, + AdditionalCoordinate, } from "types/api_types"; import type { ArbitraryObject } from "types/globals"; import { getConstructorForElementClass } from "viewer/model/helpers/typed_buffer"; import { __setFeatures } from "features"; +import type { MeshChunkDataRequestList, MeshSegmentInfo } from "admin/api/mesh.ts"; +import CustomLOD from "viewer/controller/custom_lod"; +import { createUnitCubeBufferGeometry, makeSimpleMesh } from "./geometry_helpers"; +import type { SceneGroupForMeshes } from "viewer/controller/segment_mesh_controller"; +import * as THREE from "three"; +import type { BufferGeometryWithInfo } from "viewer/controller/mesh_helpers"; const TOKEN = "secure-token"; const ANNOTATION_TYPE = "annotationTypeValue"; @@ -102,6 +114,7 @@ export interface WebknossosTestContext extends BaseTestContext { api: ApiInterface; tearDownPullQueues: () => void; receivedDataPerSaveRequest: Array; + segmentLodGroups: Record; } // Create mock objects @@ -223,6 +236,88 @@ vi.mock("admin/rest_api.ts", async () => { throw new Error("No test has mocked the return value yet here."); }, ), + getMeshfilesForDatasetLayer: vi.fn( + async ( + _dataStoreUrl: string, + _dataset: APIDataset, + _layerName: string, + ): Promise> => { + return [ + { + name: sampleMappingFileName, + mappingName: null, // Set to null to be usable for proofreading helper meshes. + formatVersion: 3, + }, + ]; + }, + ), + }; +}); + +// Mocks required to mock precomputed meshes loading +vi.mock("libs/draco.ts", async () => { + return { + getDracoLoader: vi.fn(() => ({ + decodeDracoFileAsync: createUnitCubeBufferGeometry, + })), + }; +}); + +vi.mock("admin/api/mesh.ts", async () => { + const actual = await vi.importActual("admin/api/mesh.ts"); + + const getMeshfileChunksForSegment = vi.fn( + async ( + _dataStoreUrl: string, + _datasetId: string, + _layerName: string, + _meshFile: APIMeshFileInfo, + segmentId: number, + _targetMappingName: string | null | undefined, + _editableMappingTracingId: string | null | undefined, + ): Promise => { + console.log("Requesting default mesh segment info in mocked test."); + await sleep(100); + return { + meshFormat: "draco", + lods: [ + { + chunks: [ + { + position: [0, 0, 0], + byteOffset: 0, + byteSize: 666, + unmappedSegmentId: segmentId, + }, + ], + transform: [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + ], // 4x3 matrix + }, + ], + chunkScale: [1, 1, 1], + }; + }, + ); + + const getMeshfileChunkData = vi.fn( + async ( + _dataStoreUrl: string, + _datasetId: string, + _layerName: string, + _batchDescription: MeshChunkDataRequestList, + ): Promise => { + return [new ArrayBuffer()]; + }, + ); + + return { + ...actual, + getMeshfileChunksForSegment, + getMeshfileChunkData, + // TODOM }; }); @@ -485,15 +580,81 @@ export async function setupWebknossosForTesting( ); vi.mocked(parseProtoAnnotation).mockReturnValue(_.cloneDeep(annotationProto)); + // clear segmentLodGroups + if (testContext.segmentLodGroups == null) { + testContext.segmentLodGroups = {}; + } + const { segmentLodGroups } = testContext; + Object.keys(segmentLodGroups).forEach((key) => delete segmentLodGroups[key]); setSceneController({ name: "This is a dummy scene controller so that getSceneController works in the tests.", // @ts-ignore segmentMeshController: { meshesGroupsPerSegmentId: {}, updateActiveUnmappedSegmentIdHighlighting: vi.fn(), + getLODGroupOfLayer(layerName: string): CustomLOD | undefined { + return segmentLodGroups[layerName]; + }, + addMeshFromGeometry: vi.fn( + ( + geometry: BufferGeometryWithInfo, + segmentId: number, + _scale: Vector3 | null = null, + _lod: number, + layerName: string, + _additionalCoordinates: AdditionalCoordinate[] | null | undefined, + _opacity: number | undefined, + _isMerged: boolean, + ) => { + // TODOM: make geometry, make mesh, add to internal structure + console.error("adding mesh", segmentId); + const mesh = makeSimpleMesh(geometry); + const group = new THREE.Group() as SceneGroupForMeshes; + group.add(mesh); + group.segmentId = segmentId; + if (!segmentLodGroups[layerName]) { + segmentLodGroups[layerName] = new CustomLOD(); + } + const lodGroup = segmentLodGroups[layerName]; + lodGroup.addNoLODSupportedMesh(group); + }, + ), + removeMeshById: vi.fn((segmentId: number, layerName: string, _options?: { lod: number }) => { + console.error("removing mesh", segmentId); + let groupToRemove = null; + segmentLodGroups[layerName]?.traverse((group) => { + if ((group as SceneGroupForMeshes)?.segmentId === segmentId) { + groupToRemove = group; + } + }) as SceneGroupForMeshes | undefined; + if (groupToRemove) { + segmentLodGroups[layerName]?.removeNoLODSupportedMesh(groupToRemove); + } else { + console.warn( + `Tried to remove mesh for segment ${segmentId} in mocked segmentMeshController.`, + ); + } + }), + setMeshVisibility: vi.fn( + ( + id: number, + visibility: boolean, + layerName: string, + _additionalCoordinates?: AdditionalCoordinate[] | null, + ) => { + const lodGroup = segmentLodGroups[layerName]; + lodGroup.children.forEach((group) => { + if ((group as SceneGroupForMeshes).segmentId === id) { + group.visible = visibility; + } + }); + }, + ), }, }); + Store.dispatch(sceneControllerInitializedAction()); + __setFeatures({}); try { diff --git a/frontend/javascripts/test/helpers/geometry_helpers.ts b/frontend/javascripts/test/helpers/geometry_helpers.ts new file mode 100644 index 00000000000..1cdea198f2f --- /dev/null +++ b/frontend/javascripts/test/helpers/geometry_helpers.ts @@ -0,0 +1,45 @@ +import * as THREE from "three"; + +// This function should only be used for mocking. +export function createUnitCubeBufferGeometry() { + const geometry = new THREE.BufferGeometry(); + + // 8 vertices (but we will duplicate for per-face normals if needed) + const vertices = new Float32Array([ + // Front face + 0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, + + // Back face + 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, + ]); + + // Indices for 12 triangles (2 per face) + const indices = [ + // front + 0, 1, 2, 0, 2, 3, + // right + 1, 5, 6, 1, 6, 2, + // back + 5, 4, 7, 5, 7, 6, + // left + 4, 0, 3, 4, 3, 7, + // top + 3, 2, 6, 3, 6, 7, + // bottom + 4, 5, 1, 4, 1, 0, + ]; + + geometry.setIndex(indices); + geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3)); + geometry.computeVertexNormals(); + + return geometry; +} + +// This function should only be used for mocking. +export function makeSimpleMesh(geometry: THREE.BufferGeometry) { + return new THREE.Mesh( + geometry, + new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true }), + ); +} diff --git a/frontend/javascripts/test/libs/diffable_map.spec.ts b/frontend/javascripts/test/libs/diffable_map.spec.ts index a5a42be1a74..58edbe372e9 100644 --- a/frontend/javascripts/test/libs/diffable_map.spec.ts +++ b/frontend/javascripts/test/libs/diffable_map.spec.ts @@ -397,4 +397,119 @@ describe("DiffableMap", () => { expect(merged).not.toBe(mapA); expect(merged).not.toBe(mapB); }); + + it("diffDiffableMaps should not find differences when maps are not based on each other but content is equal", () => { + const objectCount = 20; + + // Load all objects into one map + const map1 = new DiffableMap( + _.range(0, objectCount).map((index) => [index, {}] as [number, any]), + 10, + ); + + const map2 = new DiffableMap( + _.range(0, objectCount).map((index) => [index, {}] as [number, any]), + 10, + ); + + const diff = diffDiffableMaps(map1, map2, true); + const expectedDiff = { + changed: [], + onlyA: [], + onlyB: [], + }; + + expect(sort(diff.changed)).toEqual(expectedDiff.changed); + expect(sort(diff.onlyA)).toEqual(expectedDiff.onlyA); + expect(sort(diff.onlyB)).toEqual(expectedDiff.onlyB); + }); + + it("diffDiffableMaps should not find differences when maps are based on each other but content is equal", () => { + const objectCount = 20; + + // Load all objects into one map + const map1 = new DiffableMap( + _.range(0, objectCount).map((index) => [index, {}] as [number, any]), + 10, + ); + + // Change some values in map 2 and keep the value equal but not instance equal. + let map2 = map1; + map2 = map2.set(0, {}); + map2 = map2.set(2, {}); + map2 = map2.set(4, {}); + map2 = map2.set(6, {}); + map2 = map2.set(8, {}); + + const diff = diffDiffableMaps(map1, map2, true); + const expectedDiff = { + changed: [], + onlyA: [], + onlyB: [], + }; + + expect(sort(diff.changed)).toEqual(expectedDiff.changed); + expect(sort(diff.onlyA)).toEqual(expectedDiff.onlyA); + expect(sort(diff.onlyB)).toEqual(expectedDiff.onlyB); + }); + + it("diffDiffableMaps should find differences when maps are not based on each other", () => { + const objectCount = 20; + + // Load all objects into one map + const map1 = new DiffableMap( + _.range(0, objectCount).map((index) => [index, {}] as [number, any]), + 10, + ); + + const additionalKeyInMap2 = objectCount + 5; + + const map2 = new DiffableMap( + _.range(0, objectCount).map((index) => + index % 2 === 0 + ? ([index, { some: "diff" }] as [number, any]) + : ([index, {}] as [number, any]), + ), + 10, + ).set(additionalKeyInMap2, {}); + + const diff = diffDiffableMaps(map1, map2, true); + const expectedDiff = { + changed: _.range(0, objectCount).filter((index) => index % 2 === 0), + onlyA: [], + onlyB: [additionalKeyInMap2], + }; + + expect(sort(diff.changed)).toEqual(expectedDiff.changed); + expect(sort(diff.onlyA)).toEqual(expectedDiff.onlyA); + expect(sort(diff.onlyB)).toEqual(expectedDiff.onlyB); + }); + + it("diffDiffableMaps should find differences when maps are based on each other", () => { + const objectCount = 20; + + // Load all objects into one map + const map1 = new DiffableMap( + _.range(0, objectCount).map((index) => [index, {}] as [number, any]), + 10, + ); + + // Change some values in map 2 and keep the value equal but not instance equal. + const keysToChange = _.range(0, objectCount).filter((index) => index % 2 === 0); + let map2 = map1; + for (const key of keysToChange) { + map2 = map2.set(key, { some: "diff" }); + } + + const diff = diffDiffableMaps(map1, map2, true); + const expectedDiff = { + changed: keysToChange, + onlyA: [], + onlyB: [], + }; + + expect(sort(diff.changed)).toEqual(expectedDiff.changed); + expect(sort(diff.onlyA)).toEqual(expectedDiff.onlyA); + expect(sort(diff.onlyB)).toEqual(expectedDiff.onlyB); + }); }); diff --git a/frontend/javascripts/test/model/edge_collection.spec.ts b/frontend/javascripts/test/model/edge_collection.spec.ts index 83cbe514b80..02ec9ba6c83 100644 --- a/frontend/javascripts/test/model/edge_collection.spec.ts +++ b/frontend/javascripts/test/model/edge_collection.spec.ts @@ -61,7 +61,7 @@ describe("EdgeCollection", () => { }; const edgeCollectionA = new EdgeCollection().addEdges([edgeC, edgeD]); const edgeCollectionB = new EdgeCollection().addEdges([edgeA, edgeB, edgeC]); - const { onlyA, onlyB } = diffEdgeCollections(edgeCollectionA, edgeCollectionB); + const { onlyA, onlyB } = diffEdgeCollections(edgeCollectionA, edgeCollectionB, false); expect(onlyA).toEqual([edgeD]); expect(onlyB).toEqual([edgeA, edgeB]); }); @@ -85,7 +85,7 @@ describe("EdgeCollection", () => { }; const edgeCollectionA = new EdgeCollection().addEdges([edgeA, edgeB, edgeC, edgeD]); const edgeCollectionB = new EdgeCollection().addEdges([edgeA, edgeB, edgeC, edgeD]); - const { onlyA, onlyB } = diffEdgeCollections(edgeCollectionA, edgeCollectionB); + const { onlyA, onlyB } = diffEdgeCollections(edgeCollectionA, edgeCollectionB, false); expect(onlyA).toEqual([]); expect(onlyB).toEqual([]); }); @@ -143,7 +143,7 @@ describe("EdgeCollection", () => { ]; const edgeCollectionA = new EdgeCollection(5).addEdges(edges); const edgeCollectionB = new EdgeCollection(5).addEdges(edges); - const { onlyA, onlyB } = diffEdgeCollections(edgeCollectionA, edgeCollectionB); + const { onlyA, onlyB } = diffEdgeCollections(edgeCollectionA, edgeCollectionB, false); expect(onlyA).toEqual([]); expect(onlyB).toEqual([]); }); @@ -201,7 +201,7 @@ describe("EdgeCollection", () => { ].sort(edgeSort); const edgeCollectionA = new EdgeCollection(5).addEdges(edges.slice(0, 8)); const edgeCollectionB = new EdgeCollection(5).addEdges(edges.slice(1)); - const { onlyA, onlyB } = diffEdgeCollections(edgeCollectionA, edgeCollectionB); + const { onlyA, onlyB } = diffEdgeCollections(edgeCollectionA, edgeCollectionB, false); expect(onlyA.sort(edgeSort)).toEqual([edges[0]]); expect(onlyB.sort(edgeSort)).toEqual(edges.slice(8)); }); @@ -225,7 +225,7 @@ describe("EdgeCollection", () => { }; const edgeCollectionA = new EdgeCollection().addEdges([edgeA, edgeB, edgeC]); const edgeCollectionB = edgeCollectionA.addEdge(edgeD); - const { onlyA, onlyB } = diffEdgeCollections(edgeCollectionA, edgeCollectionB); + const { onlyA, onlyB } = diffEdgeCollections(edgeCollectionA, edgeCollectionB, false); expect(onlyA).toEqual([]); expect(onlyB).toEqual([edgeD]); }); @@ -249,8 +249,10 @@ describe("EdgeCollection", () => { }; const edgeCollectionA = new EdgeCollection().addEdges([edgeA, edgeB, edgeC]); const edgeCollectionB = edgeCollectionA.addEdge(edgeD, true); - const { onlyA, onlyB } = diffEdgeCollections(edgeCollectionA, edgeCollectionB); + const { onlyA, onlyB } = diffEdgeCollections(edgeCollectionA, edgeCollectionB, false); expect(onlyA).toEqual([]); expect(onlyB).toEqual([]); }); + + // TODOM: Write test to test diffEdgeCollections with useDeepEqualityCheck = true. }); diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts index 810beee01b7..317ae9a03de 100644 --- a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -20,10 +20,10 @@ import * as SkeletonTracingActions from "viewer/model/actions/skeletontracing_ac import { setActiveUserBoundingBoxId } from "viewer/model/actions/ui_actions"; import compactUpdateActions from "viewer/model/helpers/compaction/compact_update_actions"; import { diffSkeletonTracing } from "viewer/model/sagas/skeletontracing_saga"; -import type { - ApplicableSkeletonServerUpdateAction, - ApplicableSkeletonUpdateAction, - UpdateActionWithoutIsolationRequirement, +import { + ApplicableSkeletonUpdateActionNamesHelperNamesList, + type ApplicableSkeletonServerUpdateAction, + type UpdateActionWithoutIsolationRequirement, } from "viewer/model/sagas/volume/update_actions"; import { combinedReducer, type WebknossosState } from "viewer/store"; import { makeBasicGroupObject } from "viewer/view/right-border-tabs/trees_tab/tree_hierarchy_view_helpers"; @@ -70,34 +70,6 @@ const addMissingTimestampProp = ( const applyActions = chainReduce(combinedReducer); -// This helper dict exists so that we can ensure via typescript that -// the list contains all members of ApplicableSkeletonUpdateAction. As soon as -// ApplicableSkeletonUpdateAction is extended with another action, TS will complain -// if the following dictionary doesn't contain that action. -const actionNamesHelper: Record = { - updateTree: true, - createTree: true, - updateNode: true, - createNode: true, - createEdge: true, - deleteTree: true, - deleteEdge: true, - deleteNode: true, - moveTreeComponent: true, - updateTreeGroups: true, - updateTreeGroupsExpandedState: true, - updateTreeEdgesVisibility: true, - addUserBoundingBoxInSkeletonTracing: true, - updateUserBoundingBoxInSkeletonTracing: true, - updateUserBoundingBoxVisibilityInSkeletonTracing: true, - deleteUserBoundingBoxInSkeletonTracing: true, - updateActiveNode: true, - updateTreeVisibility: true, - updateTreeGroupVisibility: true, - updateActiveTree: true, -}; -const actionNamesList = Object.keys(actionNamesHelper); - describe("Update Action Application for SkeletonTracing", () => { const seenActionTypes = new Set(); @@ -327,7 +299,7 @@ describe("Update Action Application for SkeletonTracing", () => { afterAll(() => { // Ensure that each possible action is included in the testing at least once - expect(seenActionTypes).toEqual(new Set(actionNamesList)); + expect(seenActionTypes).toEqual(new Set(ApplicableSkeletonUpdateActionNamesHelperNamesList)); }); }); diff --git a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_single_user.spec.ts/merge_should_refresh_agglomerate_skeletons.json b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_single_user.spec.ts/merge_should_refresh_agglomerate_skeletons.json new file mode 100644 index 00000000000..ff9a4b7c577 --- /dev/null +++ b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_single_user.spec.ts/merge_should_refresh_agglomerate_skeletons.json @@ -0,0 +1,52 @@ +[ + { + "actions": [ + { + "name": "moveTreeComponent", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "nodeIds": [ + 7, + 8, + ], + "sourceId": 4, + "targetId": 3, + }, + }, + { + "name": "deleteTree", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "id": 4, + }, + }, + { + "name": "createEdge", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "source": 4, + "target": 7, + "treeId": 3, + }, + }, + ], + "authorId": "dummy-user-id", + "info": "["DONE_SAVING","SET_SAVE_BUSY","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","DISALLOW_SAGA_WHILE_BUSY_ACTION","REMOVE_SEGMENT","APPLY_SKELETON_UPDATE_ACTIONS_FROM_SERVER","REMOVE_MESH","MAYBE_FETCH_MESH_FILES","SET_BUSY_BLOCKING_INFO_ACTION","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE"]", + "stats": { + "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { + "branchPointCount": 2, + "edgeCount": 6, + "nodeCount": 10, + "treeCount": 4, + }, + "volumeTracingId": { + "segmentCount": 1, + }, + }, + "timestamp": 1494695001688, + "transactionGroupCount": 1, + "transactionGroupIndex": 0, + "transactionId": "dummyRequestId", + "version": 11, + }, +] \ No newline at end of file diff --git a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_single_user.spec.ts/split_should_not_refresh_unaffected_agglomerate_skeletons.json b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_single_user.spec.ts/split_should_not_refresh_unaffected_agglomerate_skeletons.json new file mode 100644 index 00000000000..19277c53b53 --- /dev/null +++ b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_single_user.spec.ts/split_should_not_refresh_unaffected_agglomerate_skeletons.json @@ -0,0 +1,136 @@ +[ + { + "actions": [ + { + "name": "createTree", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "branchPoints": [], + "color": [ + 0.6784313725490196, + 0.1411764705882353, + 0.050980392156862744, + ], + "comments": [], + "edgesAreVisible": true, + "groupId": undefined, + "id": 3, + "isVisible": true, + "metadata": [], + "name": "agglomerate 4 (volumeTracingId)", + "timestamp": 1494695001688, + "type": "AGGLOMERATE", + "updatedId": 3, + }, + }, + { + "name": "createNode", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "additionalCoordinates": [], + "bitDepth": 8, + "id": 4, + "interpolation": false, + "position": [ + 4, + 4, + 4, + ], + "radius": 1, + "resolution": 1, + "rotation": [ + 0, + 0, + 0, + ], + "timestamp": 1494695001688, + "treeId": 3, + "viewport": 0, + }, + }, + { + "name": "createNode", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "additionalCoordinates": [], + "bitDepth": 8, + "id": 5, + "interpolation": false, + "position": [ + 5, + 5, + 5, + ], + "radius": 1, + "resolution": 1, + "rotation": [ + 0, + 0, + 0, + ], + "timestamp": 1494695001688, + "treeId": 3, + "viewport": 0, + }, + }, + { + "name": "createEdge", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "source": 4, + "target": 5, + "treeId": 3, + }, + }, + ], + "authorId": "dummy-user-id", + "info": "["ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","DISALLOW_SAGA_WHILE_BUSY_ACTION","INITIALIZE_EDITABLE_MAPPING","SET_BUSY_BLOCKING_INFO_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","SNAPSHOT_MAPPING_DATA_FOR_NEXT_REBASE_ACTION","FINISH_MAPPING_INITIALIZATION","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS"]", + "stats": { + "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { + "branchPointCount": 2, + "edgeCount": 2, + "nodeCount": 5, + "treeCount": 3, + }, + "volumeTracingId": { + "segmentCount": 1, + }, + }, + "timestamp": 1494695001688, + "transactionGroupCount": 1, + "transactionGroupIndex": 0, + "transactionId": "dummyRequestId", + "version": 7, + }, + { + "actions": [ + { + "name": "splitAgglomerate", + "value": { + "actionTracingId": "volumeTracingId", + "agglomerateId": 1, + "segmentId1": 1, + "segmentId2": 2, + }, + }, + ], + "authorId": "dummy-user-id", + "info": "["INITIALIZE_EDITABLE_MAPPING","SET_BUSY_BLOCKING_INFO_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","SNAPSHOT_MAPPING_DATA_FOR_NEXT_REBASE_ACTION","FINISH_MAPPING_INITIALIZATION","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS","MIN_CUT_AGGLOMERATE","SET_BUSY_BLOCKING_INFO_ACTION"]", + "stats": { + "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { + "branchPointCount": 2, + "edgeCount": 2, + "nodeCount": 5, + "treeCount": 3, + }, + "volumeTracingId": { + "segmentCount": 1, + }, + }, + "timestamp": 1494695001688, + "transactionGroupCount": 1, + "transactionGroupIndex": 0, + "transactionId": "dummyRequestId", + "version": 8, + }, +] \ No newline at end of file diff --git a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_single_user.spec.ts/split_should_refresh_agglomerate_skeletons.json b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_single_user.spec.ts/split_should_refresh_agglomerate_skeletons.json new file mode 100644 index 00000000000..c8d225a7258 --- /dev/null +++ b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_single_user.spec.ts/split_should_refresh_agglomerate_skeletons.json @@ -0,0 +1,67 @@ +[ + { + "actions": [ + { + "name": "createTree", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "branchPoints": [], + "color": [ + 0.6784313725490196, + 0.1411764705882353, + 0.050980392156862744, + ], + "comments": [], + "edgesAreVisible": true, + "groupId": undefined, + "id": 4, + "isVisible": true, + "metadata": [], + "name": "agglomerate 1339 (volumeTracingId)", + "timestamp": 1494695001688, + "type": "AGGLOMERATE", + "updatedId": 4, + }, + }, + { + "name": "moveTreeComponent", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "nodeIds": [ + 5, + 6, + ], + "sourceId": 3, + "targetId": 4, + }, + }, + { + "name": "deleteEdge", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "source": 4, + "target": 5, + "treeId": 3, + }, + }, + ], + "authorId": "dummy-user-id", + "info": "["ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","DISALLOW_SAGA_WHILE_BUSY_ACTION","APPLY_SKELETON_UPDATE_ACTIONS_FROM_SERVER","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","SNAPSHOT_MAPPING_DATA_FOR_NEXT_REBASE_ACTION","REMOVE_MESH","MAYBE_FETCH_MESH_FILES","SET_BUSY_BLOCKING_INFO_ACTION","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE"]", + "stats": { + "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { + "branchPointCount": 2, + "edgeCount": 2, + "nodeCount": 6, + "treeCount": 4, + }, + "volumeTracingId": { + "segmentCount": 1, + }, + }, + "timestamp": 1494695001688, + "transactionGroupCount": 1, + "transactionGroupIndex": 0, + "transactionId": "dummyRequestId", + "version": 9, + }, +] \ No newline at end of file diff --git a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/load_agglomerate_tree_update_actions.json b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/load_agglomerate_tree_update_actions.json index d13ec3f8a58..cf8997dbab0 100644 --- a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/load_agglomerate_tree_update_actions.json +++ b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/load_agglomerate_tree_update_actions.json @@ -20,7 +20,7 @@ "name": "agglomerate 1 (volumeTracingId)", "timestamp": 1494695001688, "type": "AGGLOMERATE", - "updatedId": undefined, + "updatedId": 3, }, }, { @@ -102,8 +102,8 @@ "name": "createEdge", "value": { "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "source": 5, - "target": 4, + "source": 4, + "target": 5, "treeId": 3, }, }, @@ -111,14 +111,14 @@ "name": "createEdge", "value": { "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "source": 6, - "target": 4, + "source": 5, + "target": 6, "treeId": 3, }, }, ], "authorId": "dummy-user-id", - "info": "["SHIFT_SAVE_QUEUE","DONE_SAVING","SET_SAVE_BUSY","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","INITIALIZE_EDITABLE_MAPPING","SET_OTHERS_MAY_EDIT_FOR_ANNOTATION","SET_ANNOTATION_ALLOW_UPDATE","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE"]", + "info": "["SET_OTHERS_MAY_EDIT_FOR_ANNOTATION","SET_ANNOTATION_ALLOW_UPDATE","LOAD_AGGLOMERATE_SKELETON","ENSURE_HAS_ANNOTATION_MUTEX","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_HAS_NEWEST_VERSION","SET_BUSY_BLOCKING_INFO_ACTION * 2","ADD_TREES_AND_GROUPS","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE"]", "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, diff --git a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/merge_skeleton_interfered.json b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/merge_skeleton_interfered.json index dd4b7add48a..44760cad6cd 100644 --- a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/merge_skeleton_interfered.json +++ b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/merge_skeleton_interfered.json @@ -38,7 +38,7 @@ }, ], "authorId": "dummy-user-id", - "info": "["SET_VERSION_NUMBER","SET_LAST_SAVE_TIMESTAMP","SHIFT_SAVE_QUEUE","DONE_SAVING","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","MERGE_TREES","SET_BUSY_BLOCKING_INFO_ACTION"]", + "info": "["SET_VERSION_NUMBER","SET_LAST_SAVE_TIMESTAMP","SHIFT_SAVE_QUEUE","DONE_SAVING","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","MERGE_TREES","SET_BUSY_BLOCKING_INFO_ACTION"]", "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, @@ -70,7 +70,7 @@ }, ], "authorId": "dummy-user-id", - "info": "["DONE_SAVING","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","MERGE_TREES","SET_BUSY_BLOCKING_INFO_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE"]", + "info": "["DONE_SAVING","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","MERGE_TREES","SET_BUSY_BLOCKING_INFO_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE"]", "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, diff --git a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/merge_skeleton_interfered_no-op.json b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/merge_skeleton_interfered_no-op.json index 97eb68a81a6..75bdb9e828f 100644 --- a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/merge_skeleton_interfered_no-op.json +++ b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/merge_skeleton_interfered_no-op.json @@ -38,7 +38,7 @@ }, ], "authorId": "dummy-user-id", - "info": "["SET_VERSION_NUMBER","SET_LAST_SAVE_TIMESTAMP","SHIFT_SAVE_QUEUE","DONE_SAVING","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","MERGE_TREES","SET_BUSY_BLOCKING_INFO_ACTION"]", + "info": "["SET_VERSION_NUMBER","SET_LAST_SAVE_TIMESTAMP","SHIFT_SAVE_QUEUE","DONE_SAVING","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","MERGE_TREES","SET_BUSY_BLOCKING_INFO_ACTION"]", "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, @@ -70,7 +70,7 @@ }, ], "authorId": "dummy-user-id", - "info": "["DONE_SAVING","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","MERGE_TREES","SET_BUSY_BLOCKING_INFO_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE"]", + "info": "["DONE_SAVING","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","MERGE_TREES","SET_BUSY_BLOCKING_INFO_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE"]", "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, diff --git a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/merge_skeleton_simple.json b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/merge_skeleton_simple.json index cd11a6b1d15..94ffe16ffcb 100644 --- a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/merge_skeleton_simple.json +++ b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/merge_skeleton_simple.json @@ -1,312 +1,4 @@ [ - { - "actions": [ - { - "name": "createTree", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "branchPoints": [], - "color": [ - 0.6784313725490196, - 0.1411764705882353, - 0.050980392156862744, - ], - "comments": [], - "edgesAreVisible": true, - "groupId": undefined, - "id": 3, - "isVisible": true, - "metadata": [], - "name": "agglomerate 1 (volumeTracingId)", - "timestamp": 1494695001688, - "type": "AGGLOMERATE", - "updatedId": undefined, - }, - }, - { - "name": "createNode", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "additionalCoordinates": [], - "bitDepth": 8, - "id": 4, - "interpolation": false, - "position": [ - 1, - 1, - 1, - ], - "radius": 1, - "resolution": 1, - "rotation": [ - 0, - 0, - 0, - ], - "timestamp": 1494695001688, - "treeId": 3, - "viewport": 0, - }, - }, - { - "name": "createNode", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "additionalCoordinates": [], - "bitDepth": 8, - "id": 5, - "interpolation": false, - "position": [ - 2, - 2, - 2, - ], - "radius": 1, - "resolution": 1, - "rotation": [ - 0, - 0, - 0, - ], - "timestamp": 1494695001688, - "treeId": 3, - "viewport": 0, - }, - }, - { - "name": "createNode", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "additionalCoordinates": [], - "bitDepth": 8, - "id": 6, - "interpolation": false, - "position": [ - 3, - 3, - 3, - ], - "radius": 1, - "resolution": 1, - "rotation": [ - 0, - 0, - 0, - ], - "timestamp": 1494695001688, - "treeId": 3, - "viewport": 0, - }, - }, - { - "name": "createEdge", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "source": 5, - "target": 4, - "treeId": 3, - }, - }, - { - "name": "createEdge", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "source": 6, - "target": 4, - "treeId": 3, - }, - }, - ], - "authorId": "dummy-user-id", - "info": "["SET_LAST_SAVE_TIMESTAMP","SHIFT_SAVE_QUEUE","DONE_SAVING","SET_SAVE_BUSY","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","INITIALIZE_EDITABLE_MAPPING","SET_OTHERS_MAY_EDIT_FOR_ANNOTATION","SET_ANNOTATION_ALLOW_UPDATE","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS"]", - "stats": { - "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { - "branchPointCount": 2, - "edgeCount": 3, - "nodeCount": 6, - "treeCount": 3, - }, - "volumeTracingId": { - "segmentCount": 1, - }, - }, - "timestamp": 1494695001688, - "transactionGroupCount": 1, - "transactionGroupIndex": 0, - "transactionId": "dummyRequestId", - "version": 8, - }, - { - "actions": [ - { - "name": "createTree", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "branchPoints": [], - "color": [ - 0.6784313725490196, - 0.1411764705882353, - 0.050980392156862744, - ], - "comments": [], - "edgesAreVisible": true, - "groupId": undefined, - "id": 4, - "isVisible": true, - "metadata": [], - "name": "agglomerate 4 (volumeTracingId)", - "timestamp": 1494695001688, - "type": "AGGLOMERATE", - "updatedId": undefined, - }, - }, - { - "name": "createNode", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "additionalCoordinates": [], - "bitDepth": 8, - "id": 7, - "interpolation": false, - "position": [ - 4, - 4, - 4, - ], - "radius": 1, - "resolution": 1, - "rotation": [ - 0, - 0, - 0, - ], - "timestamp": 1494695001688, - "treeId": 4, - "viewport": 0, - }, - }, - { - "name": "createNode", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "additionalCoordinates": [], - "bitDepth": 8, - "id": 8, - "interpolation": false, - "position": [ - 5, - 5, - 5, - ], - "radius": 1, - "resolution": 1, - "rotation": [ - 0, - 0, - 0, - ], - "timestamp": 1494695001688, - "treeId": 4, - "viewport": 0, - }, - }, - { - "name": "createNode", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "additionalCoordinates": [], - "bitDepth": 8, - "id": 9, - "interpolation": false, - "position": [ - 6, - 6, - 6, - ], - "radius": 1, - "resolution": 1, - "rotation": [ - 0, - 0, - 0, - ], - "timestamp": 1494695001688, - "treeId": 4, - "viewport": 0, - }, - }, - { - "name": "createNode", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "additionalCoordinates": [], - "bitDepth": 8, - "id": 10, - "interpolation": false, - "position": [ - 7, - 7, - 7, - ], - "radius": 1, - "resolution": 1, - "rotation": [ - 0, - 0, - 0, - ], - "timestamp": 1494695001688, - "treeId": 4, - "viewport": 0, - }, - }, - { - "name": "createEdge", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "source": 8, - "target": 7, - "treeId": 4, - }, - }, - { - "name": "createEdge", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "source": 9, - "target": 7, - "treeId": 4, - }, - }, - { - "name": "createEdge", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "source": 10, - "target": 7, - "treeId": 4, - }, - }, - ], - "authorId": "dummy-user-id", - "info": "["DONE_SAVING","SET_SAVE_BUSY","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","INITIALIZE_EDITABLE_MAPPING","SET_OTHERS_MAY_EDIT_FOR_ANNOTATION","SET_ANNOTATION_ALLOW_UPDATE","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS"]", - "stats": { - "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { - "branchPointCount": 2, - "edgeCount": 6, - "nodeCount": 10, - "treeCount": 4, - }, - "volumeTracingId": { - "segmentCount": 1, - }, - }, - "timestamp": 1494695001688, - "transactionGroupCount": 1, - "transactionGroupIndex": 0, - "transactionId": "dummyRequestId", - "version": 9, - }, { "actions": [ { @@ -316,8 +8,6 @@ "nodeIds": [ 7, 8, - 9, - 10, ], "sourceId": 4, "targetId": 3, @@ -348,12 +38,12 @@ }, ], "authorId": "dummy-user-id", - "info": "["ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","INITIALIZE_EDITABLE_MAPPING","SET_OTHERS_MAY_EDIT_FOR_ANNOTATION","SET_ANNOTATION_ALLOW_UPDATE","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS","MERGE_TREES","SET_BUSY_BLOCKING_INFO_ACTION"]", + "info": "["ENSURE_HAS_ANNOTATION_MUTEX","ENSURE_HAS_NEWEST_VERSION","SET_BUSY_BLOCKING_INFO_ACTION * 2","SET_VERSION_NUMBER","SET_LAST_SAVE_TIMESTAMP","SHIFT_SAVE_QUEUE","DONE_SAVING","MERGE_TREES","SET_SAVE_BUSY","SET_BUSY_BLOCKING_INFO_ACTION"]", "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, - "edgeCount": 7, - "nodeCount": 10, + "edgeCount": 5, + "nodeCount": 8, "treeCount": 3, }, "volumeTracingId": { @@ -380,12 +70,12 @@ }, ], "authorId": "dummy-user-id", - "info": "["SET_ANNOTATION_ALLOW_UPDATE","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS","MERGE_TREES","SET_BUSY_BLOCKING_INFO_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE"]", + "info": "["SHIFT_SAVE_QUEUE","DONE_SAVING","MERGE_TREES","SET_SAVE_BUSY","SET_BUSY_BLOCKING_INFO_ACTION","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE"]", "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, - "edgeCount": 7, - "nodeCount": 10, + "edgeCount": 5, + "nodeCount": 8, "treeCount": 3, }, "volumeTracingId": { diff --git a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/min_cut_nodes_skeleton_incomplete.json b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/min_cut_nodes_skeleton_incomplete.json index 8d5ded8ec29..387fa0d427c 100644 --- a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/min_cut_nodes_skeleton_incomplete.json +++ b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/min_cut_nodes_skeleton_incomplete.json @@ -1,5 +1,69 @@ [ [ + { + "actions": [ + { + "name": "createTree", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "branchPoints": [], + "color": [ + 0, + 1, + 0.7568627450980392, + ], + "comments": [], + "edgesAreVisible": true, + "groupId": undefined, + "id": 4, + "isVisible": true, + "metadata": [], + "name": "explorative_2017-05-13_First_Name_Last_Name_004", + "timestamp": 1494695001688, + "type": "AGGLOMERATE", + "updatedId": 4, + }, + }, + { + "name": "moveTreeComponent", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "nodeIds": [ + 6, + ], + "sourceId": 3, + "targetId": 4, + }, + }, + { + "name": "deleteEdge", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "source": 5, + "target": 6, + "treeId": 3, + }, + }, + ], + "authorId": "dummy-user-id", + "info": "["SET_LAST_SAVE_TIMESTAMP","SHIFT_SAVE_QUEUE","DONE_SAVING","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","MIN_CUT_AGGLOMERATE_WITH_NODE_IDS","SET_BUSY_BLOCKING_INFO_ACTION","DELETE_EDGE"]", + "stats": { + "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { + "branchPointCount": 2, + "edgeCount": 2, + "nodeCount": 6, + "treeCount": 4, + }, + "volumeTracingId": { + "segmentCount": 1, + }, + }, + "timestamp": 1494695001688, + "transactionGroupCount": 1, + "transactionGroupIndex": 0, + "transactionId": "dummyRequestId", + "version": 9, + }, { "actions": [ { @@ -17,9 +81,9 @@ "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, - "edgeCount": 3, + "edgeCount": 2, "nodeCount": 6, - "treeCount": 3, + "treeCount": 4, }, "volumeTracingId": { "segmentCount": 1, @@ -29,7 +93,7 @@ "transactionGroupCount": 1, "transactionGroupIndex": 0, "transactionId": "dummyRequestId", - "version": 9, + "version": 10, }, ], [ @@ -44,13 +108,13 @@ }, ], "authorId": "dummy-user-id", - "info": "["SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","DISALLOW_SAGA_WHILE_BUSY_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","SNAPSHOT_MAPPING_DATA_FOR_NEXT_REBASE_ACTION","SET_TREE_NAME","REMOVE_SEGMENT"]", + "info": "["SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE * 2","DISALLOW_SAGA_WHILE_BUSY_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","SNAPSHOT_MAPPING_DATA_FOR_NEXT_REBASE_ACTION","SET_TREE_NAME","REMOVE_SEGMENT"]", "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, - "edgeCount": 3, + "edgeCount": 2, "nodeCount": 6, - "treeCount": 3, + "treeCount": 4, }, "volumeTracingId": { "segmentCount": 0, @@ -60,7 +124,7 @@ "transactionGroupCount": 1, "transactionGroupIndex": 0, "transactionId": "dummyRequestId", - "version": 10, + "version": 11, }, ], ] \ No newline at end of file diff --git a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/min_cut_nodes_skeleton_more_complex.json b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/min_cut_nodes_skeleton_more_complex.json index 08fa1429662..d1bda3835d4 100644 --- a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/min_cut_nodes_skeleton_more_complex.json +++ b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/min_cut_nodes_skeleton_more_complex.json @@ -1,5 +1,36 @@ [ [ + { + "actions": [ + { + "name": "deleteEdge", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "source": 5, + "target": 6, + "treeId": 3, + }, + }, + ], + "authorId": "dummy-user-id", + "info": "["SET_LAST_SAVE_TIMESTAMP","SHIFT_SAVE_QUEUE","DONE_SAVING","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","MIN_CUT_AGGLOMERATE_WITH_NODE_IDS","SET_BUSY_BLOCKING_INFO_ACTION","DELETE_EDGE"]", + "stats": { + "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { + "branchPointCount": 2, + "edgeCount": 3, + "nodeCount": 6, + "treeCount": 3, + }, + "volumeTracingId": { + "segmentCount": 1, + }, + }, + "timestamp": 1494695001688, + "transactionGroupCount": 1, + "transactionGroupIndex": 0, + "transactionId": "dummyRequestId", + "version": 9, + }, { "actions": [ { @@ -21,7 +52,7 @@ "name": "explorative_2017-05-13_First_Name_Last_Name_004", "timestamp": 1494695001688, "type": "AGGLOMERATE", - "updatedId": undefined, + "updatedId": 4, }, }, { @@ -39,8 +70,8 @@ "name": "deleteEdge", "value": { "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "source": 6, - "target": 4, + "source": 4, + "target": 6, "treeId": 3, }, }, @@ -62,7 +93,7 @@ "transactionGroupCount": 1, "transactionGroupIndex": 0, "transactionId": "dummyRequestId", - "version": 9, + "version": 10, }, { "actions": [ @@ -102,7 +133,7 @@ "transactionGroupCount": 1, "transactionGroupIndex": 0, "transactionId": "dummyRequestId", - "version": 10, + "version": 11, }, ], [ @@ -117,7 +148,7 @@ }, ], "authorId": "dummy-user-id", - "info": "["SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","DISALLOW_SAGA_WHILE_BUSY_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","SNAPSHOT_MAPPING_DATA_FOR_NEXT_REBASE_ACTION","SET_TREE_NAME","REMOVE_SEGMENT"]", + "info": "["SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE * 2","DISALLOW_SAGA_WHILE_BUSY_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","SNAPSHOT_MAPPING_DATA_FOR_NEXT_REBASE_ACTION","SET_TREE_NAME","REMOVE_SEGMENT"]", "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, @@ -133,7 +164,7 @@ "transactionGroupCount": 1, "transactionGroupIndex": 0, "transactionId": "dummyRequestId", - "version": 11, + "version": 12, }, ], ] \ No newline at end of file diff --git a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/min_cut_nodes_skeleton_simple.json b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/min_cut_nodes_skeleton_simple.json index 08fa1429662..d1bda3835d4 100644 --- a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/min_cut_nodes_skeleton_simple.json +++ b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/min_cut_nodes_skeleton_simple.json @@ -1,5 +1,36 @@ [ [ + { + "actions": [ + { + "name": "deleteEdge", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "source": 5, + "target": 6, + "treeId": 3, + }, + }, + ], + "authorId": "dummy-user-id", + "info": "["SET_LAST_SAVE_TIMESTAMP","SHIFT_SAVE_QUEUE","DONE_SAVING","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","MIN_CUT_AGGLOMERATE_WITH_NODE_IDS","SET_BUSY_BLOCKING_INFO_ACTION","DELETE_EDGE"]", + "stats": { + "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { + "branchPointCount": 2, + "edgeCount": 3, + "nodeCount": 6, + "treeCount": 3, + }, + "volumeTracingId": { + "segmentCount": 1, + }, + }, + "timestamp": 1494695001688, + "transactionGroupCount": 1, + "transactionGroupIndex": 0, + "transactionId": "dummyRequestId", + "version": 9, + }, { "actions": [ { @@ -21,7 +52,7 @@ "name": "explorative_2017-05-13_First_Name_Last_Name_004", "timestamp": 1494695001688, "type": "AGGLOMERATE", - "updatedId": undefined, + "updatedId": 4, }, }, { @@ -39,8 +70,8 @@ "name": "deleteEdge", "value": { "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "source": 6, - "target": 4, + "source": 4, + "target": 6, "treeId": 3, }, }, @@ -62,7 +93,7 @@ "transactionGroupCount": 1, "transactionGroupIndex": 0, "transactionId": "dummyRequestId", - "version": 9, + "version": 10, }, { "actions": [ @@ -102,7 +133,7 @@ "transactionGroupCount": 1, "transactionGroupIndex": 0, "transactionId": "dummyRequestId", - "version": 10, + "version": 11, }, ], [ @@ -117,7 +148,7 @@ }, ], "authorId": "dummy-user-id", - "info": "["SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","DISALLOW_SAGA_WHILE_BUSY_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","SNAPSHOT_MAPPING_DATA_FOR_NEXT_REBASE_ACTION","SET_TREE_NAME","REMOVE_SEGMENT"]", + "info": "["SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE * 2","DISALLOW_SAGA_WHILE_BUSY_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","SNAPSHOT_MAPPING_DATA_FOR_NEXT_REBASE_ACTION","SET_TREE_NAME","REMOVE_SEGMENT"]", "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, @@ -133,7 +164,7 @@ "transactionGroupCount": 1, "transactionGroupIndex": 0, "transactionId": "dummyRequestId", - "version": 11, + "version": 12, }, ], ] \ No newline at end of file diff --git a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/perform_merge_trees_proofreading_update_actions.json b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/perform_merge_trees_proofreading_update_actions.json index d4db01a6741..cf8997dbab0 100644 --- a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/perform_merge_trees_proofreading_update_actions.json +++ b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/perform_merge_trees_proofreading_update_actions.json @@ -20,7 +20,7 @@ "name": "agglomerate 1 (volumeTracingId)", "timestamp": 1494695001688, "type": "AGGLOMERATE", - "updatedId": undefined, + "updatedId": 3, }, }, { @@ -102,8 +102,8 @@ "name": "createEdge", "value": { "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "source": 5, - "target": 4, + "source": 4, + "target": 5, "treeId": 3, }, }, @@ -111,14 +111,14 @@ "name": "createEdge", "value": { "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "source": 6, - "target": 4, + "source": 5, + "target": 6, "treeId": 3, }, }, ], "authorId": "dummy-user-id", - "info": "["SET_LAST_SAVE_TIMESTAMP","SHIFT_SAVE_QUEUE","DONE_SAVING","SET_SAVE_BUSY","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","INITIALIZE_EDITABLE_MAPPING","SET_OTHERS_MAY_EDIT_FOR_ANNOTATION","SET_ANNOTATION_ALLOW_UPDATE","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS"]", + "info": "["SET_OTHERS_MAY_EDIT_FOR_ANNOTATION","SET_ANNOTATION_ALLOW_UPDATE","LOAD_AGGLOMERATE_SKELETON","ENSURE_HAS_ANNOTATION_MUTEX","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_HAS_NEWEST_VERSION","SET_BUSY_BLOCKING_INFO_ACTION * 2","ADD_TREES_AND_GROUPS","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE"]", "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, @@ -136,196 +136,4 @@ "transactionId": "dummyRequestId", "version": 7, }, - { - "actions": [ - { - "name": "createTree", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "branchPoints": [], - "color": [ - 0.6784313725490196, - 0.1411764705882353, - 0.050980392156862744, - ], - "comments": [], - "edgesAreVisible": true, - "groupId": undefined, - "id": 4, - "isVisible": true, - "metadata": [], - "name": "agglomerate 4 (volumeTracingId)", - "timestamp": 1494695001688, - "type": "AGGLOMERATE", - "updatedId": undefined, - }, - }, - { - "name": "createNode", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "additionalCoordinates": [], - "bitDepth": 8, - "id": 7, - "interpolation": false, - "position": [ - 4, - 4, - 4, - ], - "radius": 1, - "resolution": 1, - "rotation": [ - 0, - 0, - 0, - ], - "timestamp": 1494695001688, - "treeId": 4, - "viewport": 0, - }, - }, - { - "name": "createNode", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "additionalCoordinates": [], - "bitDepth": 8, - "id": 8, - "interpolation": false, - "position": [ - 5, - 5, - 5, - ], - "radius": 1, - "resolution": 1, - "rotation": [ - 0, - 0, - 0, - ], - "timestamp": 1494695001688, - "treeId": 4, - "viewport": 0, - }, - }, - { - "name": "createEdge", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "source": 8, - "target": 7, - "treeId": 4, - }, - }, - ], - "authorId": "dummy-user-id", - "info": "["DONE_SAVING","SET_SAVE_BUSY","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","INITIALIZE_EDITABLE_MAPPING","SET_OTHERS_MAY_EDIT_FOR_ANNOTATION","SET_ANNOTATION_ALLOW_UPDATE","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS"]", - "stats": { - "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { - "branchPointCount": 2, - "edgeCount": 4, - "nodeCount": 8, - "treeCount": 4, - }, - "volumeTracingId": { - "segmentCount": 1, - }, - }, - "timestamp": 1494695001688, - "transactionGroupCount": 1, - "transactionGroupIndex": 0, - "transactionId": "dummyRequestId", - "version": 8, - }, - { - "actions": [ - { - "name": "moveTreeComponent", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "nodeIds": [ - 7, - 8, - ], - "sourceId": 4, - "targetId": 3, - }, - }, - { - "name": "deleteTree", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "id": 4, - }, - }, - { - "name": "createEdge", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "source": 6, - "target": 7, - "treeId": 3, - }, - }, - { - "name": "updateActiveNode", - "value": { - "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "activeNode": 6, - }, - }, - ], - "authorId": "dummy-user-id", - "info": "["ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","INITIALIZE_EDITABLE_MAPPING","SET_OTHERS_MAY_EDIT_FOR_ANNOTATION","SET_ANNOTATION_ALLOW_UPDATE","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS","MERGE_TREES","SET_BUSY_BLOCKING_INFO_ACTION"]", - "stats": { - "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { - "branchPointCount": 2, - "edgeCount": 5, - "nodeCount": 8, - "treeCount": 3, - }, - "volumeTracingId": { - "segmentCount": 1, - }, - }, - "timestamp": 1494695001688, - "transactionGroupCount": 1, - "transactionGroupIndex": 0, - "transactionId": "dummyRequestId", - "version": 9, - }, - { - "actions": [ - { - "name": "mergeAgglomerate", - "value": { - "actionTracingId": "volumeTracingId", - "agglomerateId1": 1, - "agglomerateId2": 4, - "segmentId1": 3, - "segmentId2": 4, - }, - }, - ], - "authorId": "dummy-user-id", - "info": "["SET_ANNOTATION_ALLOW_UPDATE","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS","MERGE_TREES","SET_BUSY_BLOCKING_INFO_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE"]", - "stats": { - "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { - "branchPointCount": 2, - "edgeCount": 5, - "nodeCount": 8, - "treeCount": 3, - }, - "volumeTracingId": { - "segmentCount": 1, - }, - }, - "timestamp": 1494695001688, - "transactionGroupCount": 1, - "transactionGroupIndex": 0, - "transactionId": "dummyRequestId", - "version": 10, - }, ] \ No newline at end of file diff --git a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/perform_min_cut_with_nodes_proofreading.json b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/perform_min_cut_with_nodes_proofreading.json index d13ec3f8a58..b27ff9b6ab9 100644 --- a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/perform_min_cut_with_nodes_proofreading.json +++ b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/perform_min_cut_with_nodes_proofreading.json @@ -20,7 +20,7 @@ "name": "agglomerate 1 (volumeTracingId)", "timestamp": 1494695001688, "type": "AGGLOMERATE", - "updatedId": undefined, + "updatedId": 3, }, }, { @@ -102,8 +102,8 @@ "name": "createEdge", "value": { "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "source": 5, - "target": 4, + "source": 4, + "target": 5, "treeId": 3, }, }, @@ -111,14 +111,14 @@ "name": "createEdge", "value": { "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", - "source": 6, - "target": 4, + "source": 5, + "target": 6, "treeId": 3, }, }, ], "authorId": "dummy-user-id", - "info": "["SHIFT_SAVE_QUEUE","DONE_SAVING","SET_SAVE_BUSY","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","INITIALIZE_EDITABLE_MAPPING","SET_OTHERS_MAY_EDIT_FOR_ANNOTATION","SET_ANNOTATION_ALLOW_UPDATE","LOAD_AGGLOMERATE_SKELETON","ADD_TREES_AND_GROUPS","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE"]", + "info": "["SET_OTHERS_MAY_EDIT_FOR_ANNOTATION","SET_ANNOTATION_ALLOW_UPDATE","LOAD_AGGLOMERATE_SKELETON","ENSURE_HAS_ANNOTATION_MUTEX","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_HAS_NEWEST_VERSION","SET_BUSY_BLOCKING_INFO_ACTION * 2","ADD_TREES_AND_GROUPS","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE * 2"]", "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, diff --git a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/split_skeleton_interfered_merge.json b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/split_skeleton_interfered_merge.json index 822895ab403..4eec8a522cd 100644 --- a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/split_skeleton_interfered_merge.json +++ b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/split_skeleton_interfered_merge.json @@ -1,5 +1,69 @@ [ [ + { + "actions": [ + { + "name": "createTree", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "branchPoints": [], + "color": [ + 0, + 1, + 0.7568627450980392, + ], + "comments": [], + "edgesAreVisible": true, + "groupId": undefined, + "id": 4, + "isVisible": true, + "metadata": [], + "name": "explorative_2017-05-13_First_Name_Last_Name_004", + "timestamp": 1494695001688, + "type": "AGGLOMERATE", + "updatedId": 4, + }, + }, + { + "name": "moveTreeComponent", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "nodeIds": [ + 6, + ], + "sourceId": 3, + "targetId": 4, + }, + }, + { + "name": "deleteEdge", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "source": 5, + "target": 6, + "treeId": 3, + }, + }, + ], + "authorId": "dummy-user-id", + "info": "["SET_VERSION_NUMBER","SET_LAST_SAVE_TIMESTAMP","SHIFT_SAVE_QUEUE","DONE_SAVING","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","DELETE_EDGE","SET_BUSY_BLOCKING_INFO_ACTION"]", + "stats": { + "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { + "branchPointCount": 2, + "edgeCount": 2, + "nodeCount": 6, + "treeCount": 4, + }, + "volumeTracingId": { + "segmentCount": 1, + }, + }, + "timestamp": 1494695001688, + "transactionGroupCount": 1, + "transactionGroupIndex": 0, + "transactionId": "dummyRequestId", + "version": 9, + }, { "actions": [ { @@ -13,13 +77,13 @@ }, ], "authorId": "dummy-user-id", - "info": "["SET_VERSION_NUMBER","SET_LAST_SAVE_TIMESTAMP","SHIFT_SAVE_QUEUE","DONE_SAVING","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","DELETE_EDGE","SET_BUSY_BLOCKING_INFO_ACTION"]", + "info": "["SET_VERSION_NUMBER","SET_LAST_SAVE_TIMESTAMP","SHIFT_SAVE_QUEUE","DONE_SAVING","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","DELETE_EDGE","SET_BUSY_BLOCKING_INFO_ACTION"]", "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, - "edgeCount": 3, + "edgeCount": 2, "nodeCount": 6, - "treeCount": 3, + "treeCount": 4, }, "volumeTracingId": { "segmentCount": 1, @@ -29,38 +93,53 @@ "transactionGroupCount": 1, "transactionGroupIndex": 0, "transactionId": "dummyRequestId", - "version": 9, + "version": 10, }, ], [ { "actions": [ { - "name": "deleteSegment", + "name": "updateTree", "value": { - "actionTracingId": "volumeTracingId", - "id": 1, + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "branchPoints": [], + "color": [ + 0, + 1, + 0.7568627450980392, + ], + "comments": [], + "edgesAreVisible": true, + "groupId": undefined, + "id": 4, + "isVisible": true, + "metadata": [], + "name": "agglomerate 1339 (volumeTracingId)", + "timestamp": 1494695001688, + "type": "AGGLOMERATE", + "updatedId": 4, }, }, ], "authorId": "dummy-user-id", - "info": "["SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","DISALLOW_SAGA_WHILE_BUSY_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","SNAPSHOT_MAPPING_DATA_FOR_NEXT_REBASE_ACTION","SET_TREE_NAME","REMOVE_SEGMENT"]", + "info": "["DONE_SAVING","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE * 2","DISALLOW_SAGA_WHILE_BUSY_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","SNAPSHOT_MAPPING_DATA_FOR_NEXT_REBASE_ACTION","SET_TREE_NAME * 2"]", "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, - "edgeCount": 3, + "edgeCount": 2, "nodeCount": 6, - "treeCount": 3, + "treeCount": 4, }, "volumeTracingId": { - "segmentCount": 0, + "segmentCount": 1, }, }, "timestamp": 1494695001688, "transactionGroupCount": 1, "transactionGroupIndex": 0, "transactionId": "dummyRequestId", - "version": 10, + "version": 11, }, ], ] \ No newline at end of file diff --git a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/split_skeleton_interfered_no-op.json b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/split_skeleton_interfered_no-op.json index f1d5ede6722..52e97852a89 100644 --- a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/split_skeleton_interfered_no-op.json +++ b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/split_skeleton_interfered_no-op.json @@ -1,4 +1,68 @@ [ + { + "actions": [ + { + "name": "createTree", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "branchPoints": [], + "color": [ + 0, + 1, + 0.7568627450980392, + ], + "comments": [], + "edgesAreVisible": true, + "groupId": undefined, + "id": 4, + "isVisible": true, + "metadata": [], + "name": "explorative_2017-05-13_First_Name_Last_Name_004", + "timestamp": 1494695001688, + "type": "AGGLOMERATE", + "updatedId": 4, + }, + }, + { + "name": "moveTreeComponent", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "nodeIds": [ + 6, + ], + "sourceId": 3, + "targetId": 4, + }, + }, + { + "name": "deleteEdge", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "source": 5, + "target": 6, + "treeId": 3, + }, + }, + ], + "authorId": "dummy-user-id", + "info": "["SET_VERSION_NUMBER","SET_LAST_SAVE_TIMESTAMP","SHIFT_SAVE_QUEUE","DONE_SAVING","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","DELETE_EDGE","SET_BUSY_BLOCKING_INFO_ACTION"]", + "stats": { + "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { + "branchPointCount": 2, + "edgeCount": 2, + "nodeCount": 6, + "treeCount": 4, + }, + "volumeTracingId": { + "segmentCount": 1, + }, + }, + "timestamp": 1494695001688, + "transactionGroupCount": 1, + "transactionGroupIndex": 0, + "transactionId": "dummyRequestId", + "version": 9, + }, { "actions": [ { @@ -12,13 +76,13 @@ }, ], "authorId": "dummy-user-id", - "info": "["SET_VERSION_NUMBER","SET_LAST_SAVE_TIMESTAMP","SHIFT_SAVE_QUEUE","DONE_SAVING","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","DELETE_EDGE","SET_BUSY_BLOCKING_INFO_ACTION"]", + "info": "["SET_VERSION_NUMBER","SET_LAST_SAVE_TIMESTAMP","SHIFT_SAVE_QUEUE","DONE_SAVING","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","DELETE_EDGE","SET_BUSY_BLOCKING_INFO_ACTION"]", "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, - "edgeCount": 3, + "edgeCount": 2, "nodeCount": 6, - "treeCount": 3, + "treeCount": 4, }, "volumeTracingId": { "segmentCount": 1, @@ -28,6 +92,6 @@ "transactionGroupCount": 1, "transactionGroupIndex": 0, "transactionId": "dummyRequestId", - "version": 9, + "version": 10, }, ] \ No newline at end of file diff --git a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/split_skeleton_simple.json b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/split_skeleton_simple.json index b884e8a4c83..058e18dc4db 100644 --- a/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/split_skeleton_simple.json +++ b/frontend/javascripts/test/sagas/proofreading/__snapshots__/proofreading_skeleton_interaction.spec.ts/split_skeleton_simple.json @@ -25,13 +25,13 @@ }, ], "authorId": "dummy-user-id", - "info": "["DONE_SAVING","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE","DISALLOW_SAGA_WHILE_BUSY_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","SNAPSHOT_MAPPING_DATA_FOR_NEXT_REBASE_ACTION","SET_TREE_NAME"]", + "info": "["DONE_SAVING","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE * 2","DISALLOW_SAGA_WHILE_BUSY_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","SNAPSHOT_MAPPING_DATA_FOR_NEXT_REBASE_ACTION","SET_TREE_NAME"]", "stats": { "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { "branchPointCount": 2, - "edgeCount": 3, + "edgeCount": 2, "nodeCount": 6, - "treeCount": 3, + "treeCount": 4, }, "volumeTracingId": { "segmentCount": 1, @@ -41,6 +41,50 @@ "transactionGroupCount": 1, "transactionGroupIndex": 0, "transactionId": "dummyRequestId", - "version": 10, + "version": 11, + }, + { + "actions": [ + { + "name": "updateTree", + "value": { + "actionTracingId": "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13", + "branchPoints": [], + "color": [ + 0, + 1, + 0.7568627450980392, + ], + "comments": [], + "edgesAreVisible": true, + "groupId": undefined, + "id": 4, + "isVisible": true, + "metadata": [], + "name": "agglomerate 1340 (volumeTracingId)", + "timestamp": 1494695001688, + "type": "AGGLOMERATE", + "updatedId": 4, + }, + }, + ], + "authorId": "dummy-user-id", + "info": "["DONE_SAVING","SET_SAVE_BUSY","SET_USER_HOLDING_MUTEX","SET_IS_MUTEX_ACQUIRED","ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE * 2","DISALLOW_SAGA_WHILE_BUSY_ACTION","SET_MAPPING","FINISH_MAPPING_INITIALIZATION","SNAPSHOT_MAPPING_DATA_FOR_NEXT_REBASE_ACTION","SET_TREE_NAME * 2"]", + "stats": { + "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13": { + "branchPointCount": 2, + "edgeCount": 2, + "nodeCount": 6, + "treeCount": 4, + }, + "volumeTracingId": { + "segmentCount": 1, + }, + }, + "timestamp": 1494695001688, + "transactionGroupCount": 1, + "transactionGroupIndex": 0, + "transactionId": "dummyRequestId", + "version": 12, }, ] \ No newline at end of file diff --git a/frontend/javascripts/test/sagas/proofreading/proofreading_auxiliary_mesh_loading.spec.ts b/frontend/javascripts/test/sagas/proofreading/proofreading_auxiliary_mesh_loading.spec.ts new file mode 100644 index 00000000000..3be65168a9d --- /dev/null +++ b/frontend/javascripts/test/sagas/proofreading/proofreading_auxiliary_mesh_loading.spec.ts @@ -0,0 +1,310 @@ +import { call, put, take } from "redux-saga/effects"; +import { type WebknossosTestContext, setupWebknossosForTesting } from "test/helpers/apiHelpers"; +import { WkDevFlags } from "viewer/api/wk_dev"; +import { getMappingInfo } from "viewer/model/accessors/dataset_accessor"; +import { + minCutAgglomerateWithPositionAction, + proofreadAtPosition, + proofreadMergeAction, +} from "viewer/model/actions/proofread_actions"; +import { + setActiveCellAction, + updateSegmentAction, +} from "viewer/model/actions/volumetracing_actions"; +import { type Saga, select } from "viewer/model/sagas/effect-generators"; +import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; +import { type WebknossosState, startSaga } from "viewer/store"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { initialMapping } from "./proofreading_fixtures"; +import { + initializeMappingAndTool, + makeMappingEditableHelper, + mockInitialBucketAndAgglomerateData, +} from "./proofreading_test_utils"; +import { cancel, delay, takeEvery } from "typed-redux-saga"; +import { + setOthersMayEditForAnnotationAction, + type FinishedLoadingMeshAction, + type RemoveMeshAction, +} from "viewer/model/actions/annotation_actions"; +import type { Task } from "@redux-saga/types"; +import { Store } from "viewer/singletons"; +import _ from "lodash"; + +describe("Proofreading (with auxiliary mesh loading enabled)", () => { + const initialLiveCollab = WkDevFlags.liveCollab; + beforeEach(async (context) => { + WkDevFlags.liveCollab = true; + await setupWebknossosForTesting(context, "hybrid"); + }); + + afterEach(async (context) => { + WkDevFlags.liveCollab = initialLiveCollab; + context.tearDownPullQueues(); + // Saving after each test and checking that the root saga didn't crash, + expect(hasRootSagaCrashed()).toBe(false); + }); + + function getAllCurrentlyLoadedMeshIds(context: WebknossosTestContext) { + const loadedMeshIds = new Set(); + for (const layerName of Object.keys(context.segmentLodGroups)) { + for (const lodGroup of context.segmentLodGroups[layerName].children) { + for (const meshGroup of lodGroup.children) { + if ("segmentId" in meshGroup) { + loadedMeshIds.add(meshGroup.segmentId); + } + } + } + } + return loadedMeshIds; + } + + function* trackRemovedMeshActions(): Saga<[Set, Task]> { + const removedMeshes = new Set(); + function handleRemoveMesh(action: RemoveMeshAction) { + if ("segmentId" in action) { + removedMeshes.add(action.segmentId); + } + } + const forkedEffect = (yield* takeEvery("REMOVE_MESH", handleRemoveMesh)) as Task; + return [removedMeshes, forkedEffect]; + } + + function* trackAddedMeshActions(): Saga<[Set, Task]> { + const addedMeshes = new Set(); + function handleAddedMesh(action: FinishedLoadingMeshAction) { + if ("segmentId" in action) { + addedMeshes.add(action.segmentId); + } + } + const forkedEffect = (yield* takeEvery("FINISHED_LOADING_MESH", handleAddedMesh)) as Task; + return [addedMeshes, forkedEffect]; + } + + function* loadAgglomerateMeshes(agglomerateIds: number[]): Saga { + for (const id of agglomerateIds) { + yield put(proofreadAtPosition([id, id, id])); + yield take("FINISHED_LOADING_MESH"); + } + } + + it("should load auxiliary meshes", async (context: WebknossosTestContext) => { + const _backendMock = mockInitialBucketAndAgglomerateData(context); + + const task = startSaga(function* task(): Saga { + const { tracingId } = yield select((state: WebknossosState) => state.annotation.volumes[0]); + yield call(initializeMappingAndTool, context, tracingId); + + // Set up the merge-related segment partners. Normally, this would happen + // due to the user's interactions. + yield loadAgglomerateMeshes([1]); + const loadedMeshIds = getAllCurrentlyLoadedMeshIds(context); + expect([...loadedMeshIds]).toEqual([1]); + }); + await task.toPromise(); + }); + + it("should reload auxiliary meshes after merge", async (context: WebknossosTestContext) => { + const _backendMock = mockInitialBucketAndAgglomerateData(context); + + const task = startSaga(function* task(): Saga { + const { tracingId } = yield select((state: WebknossosState) => state.annotation.volumes[0]); + yield call(initializeMappingAndTool, context, tracingId); + + // Set up the merge-related segment partners. Normally, this would happen + // due to the user's interactions. + yield loadAgglomerateMeshes([1, 6]); + + yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); + yield put(setActiveCellAction(1)); + // Give mesh loading a little time + const loadedMeshIds = getAllCurrentlyLoadedMeshIds(context); + expect(_.sortBy([...loadedMeshIds])).toEqual([1, 6]); + yield loadAgglomerateMeshes([4]); + + const loadedMeshIds2 = getAllCurrentlyLoadedMeshIds(context); + expect(_.sortBy([...loadedMeshIds2])).toEqual([1, 4, 6]); + + // Execute the actual merge and wait for the finished mapping. + const [removedMeshes, forkedEffect1] = yield* trackRemovedMeshActions(); + const [addedMeshes, forkedEffect2] = yield* trackAddedMeshActions(); + yield put(proofreadMergeAction([4, 4, 4], 1)); + yield take("FINISH_MAPPING_INITIALIZATION"); + + yield take("FINISHED_LOADING_MESH"); + const loadedMeshIdsAfterMerge = getAllCurrentlyLoadedMeshIds(context); + expect(_.sortBy([...loadedMeshIdsAfterMerge])).toEqual([1, 6]); + expect(_.sortBy([...removedMeshes])).toEqual([1, 4]); + expect([...addedMeshes]).toEqual([1]); + yield cancel(forkedEffect1); + yield cancel(forkedEffect2); + }); + await task.toPromise(); + }); + + it("should reload auxiliary meshes after split", async (context: WebknossosTestContext) => { + const { mocks } = context; + const _backendMock = mockInitialBucketAndAgglomerateData(context); + + const task = startSaga(function* task(): Saga { + const { tracingId } = yield select((state: WebknossosState) => state.annotation.volumes[0]); + yield call(initializeMappingAndTool, context, tracingId); + + // Set up the merge-related segment partners. Normally, this would happen + // due to the user's interactions. + yield loadAgglomerateMeshes([1, 2, 4]); + + yield take("FINISHED_LOADING_MESH"); + yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); + yield put(setActiveCellAction(1)); + // Give mesh loading a little time + const loadedMeshIds = getAllCurrentlyLoadedMeshIds(context); + expect(_.sortBy([...loadedMeshIds])).toEqual([1, 4]); + + // Prepare the server's reply for the upcoming split. + vi.mocked(mocks.getEdgesForAgglomerateMinCut).mockReturnValue( + Promise.resolve([ + { + position1: [1, 1, 1], + position2: [2, 2, 2], + segmentId1: 1, + segmentId2: 2, + }, + ]), + ); + + // Execute the actual merge and wait for the finished mapping. + const [removedMeshes, forkedEffect1] = yield* trackRemovedMeshActions(); + const [addedMeshes, forkedEffect2] = yield* trackAddedMeshActions(); + // Execute the split and wait for the auxiliary meshes being reloaded properly. + yield put(minCutAgglomerateWithPositionAction([2, 2, 2], 2, 1)); + yield take("FINISH_MAPPING_INITIALIZATION"); + yield take("FINISHED_LOADING_MESH"); + yield take("FINISHED_LOADING_MESH"); + + const loadedMeshIdsAfterMerge = getAllCurrentlyLoadedMeshIds(context); + expect(_.sortBy([...loadedMeshIdsAfterMerge])).toEqual([1, 4, 1339]); + expect(_.sortBy([...removedMeshes])).toEqual([1, 1339]); // Although 1339 is not loaded it is tried to be removed by the proofreading saga to refresh it. + expect(_.sortBy([...addedMeshes])).toEqual([1, 1339]); + yield cancel(forkedEffect1); + yield cancel(forkedEffect2); + }); + await task.toPromise(); + }); + + it("should load auxiliary meshes when merging agglomerates and incorporating an interfering merge action from the backend", async (context: WebknossosTestContext) => { + const backendMock = mockInitialBucketAndAgglomerateData(context); + + backendMock.planVersionInjection(9, [ + { + name: "mergeAgglomerate", + value: { + actionTracingId: "volumeTracingId", + segmentId1: 5, + segmentId2: 6, + agglomerateId1: 4, + agglomerateId2: 6, + }, + }, + ]); + + const { annotation } = Store.getState(); + const { tracingId } = annotation.volumes[0]; + + const task = startSaga(function* task() { + yield call(initializeMappingAndTool, context, tracingId); + yield put(setOthersMayEditForAnnotationAction(true)); + + // Load all meshes for all affected agglomerate meshes and one more. + yield loadAgglomerateMeshes([4, 6, 1]); + + const loadedMeshIds = getAllCurrentlyLoadedMeshIds(context); + expect(_.sortBy([...loadedMeshIds])).toEqual([1, 4, 6]); + + // Set up the merge-related segment partners. Normally, this would happen + // due to the user's interactions. + yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); + yield put(setActiveCellAction(1)); + + yield makeMappingEditableHelper(); + + const [removedMeshes, forkedEffect1] = yield* trackRemovedMeshActions(); + const [addedMeshes, forkedEffect2] = yield* trackAddedMeshActions(); + yield put( + proofreadMergeAction( + [4, 4, 4], // unmappedId=4 / mappedId=4 at this position + 1, // unmappedId=1 maps to 1 + ), + ); + yield take("FINISH_MAPPING_INITIALIZATION"); + yield take("FINISHED_LOADING_MESH"); + + const loadedMeshIdsAfterMerge = getAllCurrentlyLoadedMeshIds(context); + expect(_.sortBy([...loadedMeshIdsAfterMerge])).toEqual([1]); + expect(_.sortBy([...removedMeshes])).toEqual([1, 4, 6]); + expect(_.sortBy([...addedMeshes])).toEqual([1]); + yield cancel(forkedEffect1); + yield cancel(forkedEffect2); + }); + + await task.toPromise(); + }); + + it("should load auxiliary meshes when merging agglomerates and incorporating an interfering split action from the backend", async (context: WebknossosTestContext) => { + const backendMock = mockInitialBucketAndAgglomerateData(context); + + backendMock.planVersionInjection(9, [ + { + name: "splitAgglomerate", + value: { + actionTracingId: "volumeTracingId", + segmentId1: 3, + segmentId2: 2, + agglomerateId: 1, + }, + }, + ]); + + const { annotation } = Store.getState(); + const { tracingId } = annotation.volumes[0]; + + const task = startSaga(function* task() { + yield call(initializeMappingAndTool, context, tracingId); + yield put(setOthersMayEditForAnnotationAction(true)); + + // Load all meshes for all affected agglomerate meshes and one more. + yield loadAgglomerateMeshes([4, 6, 1]); + + const loadedMeshIds = getAllCurrentlyLoadedMeshIds(context); + expect(_.sortBy([...loadedMeshIds])).toEqual([1, 4, 6]); + + // Set up the merge-related segment partners. Normally, this would happen + // due to the user's interactions. + yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); + yield put(setActiveCellAction(1)); + + yield makeMappingEditableHelper(); + + const [removedMeshes, forkedEffect1] = yield* trackRemovedMeshActions(); + const [addedMeshes, forkedEffect2] = yield* trackAddedMeshActions(); + yield put( + proofreadMergeAction( + [4, 4, 4], // unmappedId=4 / mappedId=4 at this position + 1, // unmappedId=1 maps to 1 + ), + ); + yield take("FINISH_MAPPING_INITIALIZATION"); + yield take("FINISHED_LOADING_MESH"); + yield delay(2000); + + const loadedMeshIdsAfterMerge = getAllCurrentlyLoadedMeshIds(context); + expect(_.sortBy([...loadedMeshIdsAfterMerge])).toEqual([1, 1339]); + expect(_.sortBy([...removedMeshes])).toEqual([1, 4]); + expect(_.sortBy([...addedMeshes])).toEqual([1, 1339]); + yield cancel(forkedEffect1); + yield cancel(forkedEffect2); + }); + + await task.toPromise(); + }); +}); diff --git a/frontend/javascripts/test/sagas/proofreading/proofreading_mesh_interaction.spec.ts b/frontend/javascripts/test/sagas/proofreading/proofreading_mesh_interaction.spec.ts index fd15d8a91cb..6ecb2922cf4 100644 --- a/frontend/javascripts/test/sagas/proofreading/proofreading_mesh_interaction.spec.ts +++ b/frontend/javascripts/test/sagas/proofreading/proofreading_mesh_interaction.spec.ts @@ -2,7 +2,6 @@ import type { MinCutTargetEdge } from "admin/rest_api"; import _ from "lodash"; import { call, put, take } from "redux-saga/effects"; import { type WebknossosTestContext, setupWebknossosForTesting } from "test/helpers/apiHelpers"; -import { delay } from "typed-redux-saga"; import { WkDevFlags } from "viewer/api/wk_dev"; import type { Vector3 } from "viewer/constants"; import { getMappingInfo } from "viewer/model/accessors/dataset_accessor"; @@ -18,9 +17,8 @@ import { setActiveCellAction, updateSegmentAction, } from "viewer/model/actions/volumetracing_actions"; -import { select } from "viewer/model/sagas/effect-generators"; +import { type Saga, select } from "viewer/model/sagas/effect-generators"; import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; -import { createEditableMapping } from "viewer/model/sagas/volume/proofread_saga"; import { Store } from "viewer/singletons"; import { type ActiveMappingInfo, @@ -32,6 +30,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { initialMapping } from "./proofreading_fixtures"; import { initializeMappingAndTool, + makeMappingEditableHelper, mockInitialBucketAndAgglomerateData, } from "./proofreading_test_utils"; @@ -49,9 +48,7 @@ describe("Proofreading (with mesh actions)", () => { expect(hasRootSagaCrashed()).toBe(false); }); - function* simulateMergeAgglomeratesViaMeshes( - context: WebknossosTestContext, - ): Generator { + function* simulateMergeAgglomeratesViaMeshes(context: WebknossosTestContext): Saga { const { api } = context; const { tracingId } = yield select((state: WebknossosState) => state.annotation.volumes[0]); yield call(initializeMappingAndTool, context, tracingId); @@ -66,7 +63,7 @@ describe("Proofreading (with mesh actions)", () => { yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); yield put(setActiveCellAction(1, undefined, null, 1)); - yield call(createEditableMapping); + yield makeMappingEditableHelper(); // After making the mapping editable, it should not have changed (as no other user did any update actions in between). const mapping1 = yield select( @@ -115,7 +112,7 @@ describe("Proofreading (with mesh actions)", () => { const { annotation } = Store.getState(); const { tracingId } = annotation.volumes[0]; - const task = startSaga(function* task(): Generator { + const task = startSaga(function* task(): Saga { yield simulateMergeAgglomeratesViaMeshes(context); const finalMapping = yield select( @@ -162,7 +159,7 @@ describe("Proofreading (with mesh actions)", () => { const { annotation } = Store.getState(); const { tracingId } = annotation.volumes[0]; - const task = startSaga(function* task(): Generator { + const task = startSaga(function* task(): Saga { yield simulateMergeAgglomeratesViaMeshes(context); const finalMapping = yield select( @@ -220,9 +217,7 @@ describe("Proofreading (with mesh actions)", () => { }, ); - function* simulateSplitAgglomeratesViaMeshes( - context: WebknossosTestContext, - ): Generator { + function* simulateSplitAgglomeratesViaMeshes(context: WebknossosTestContext): Saga { const { api } = context; const { tracingId } = yield select((state: WebknossosState) => state.annotation.volumes[0]); const expectedInitialMapping = new Map([ @@ -247,7 +242,7 @@ describe("Proofreading (with mesh actions)", () => { yield put(updateSegmentAction(6, { somePosition: [1337, 1337, 1337] }, tracingId)); yield put(setActiveCellAction(6, undefined, null, 1337)); - yield call(createEditableMapping); + yield makeMappingEditableHelper(); // After making the mapping editable, it should not have changed (as no other user did any update actions in between). const mapping1 = yield select( @@ -291,7 +286,7 @@ describe("Proofreading (with mesh actions)", () => { const { annotation } = Store.getState(); const { tracingId } = annotation.volumes[0]; - const task = startSaga(function* task(): Generator { + const task = startSaga(function* task(): Saga { yield simulateSplitAgglomeratesViaMeshes(context); const mergeSaveActionBatch = context.receivedDataPerSaveRequest.at(-1)![0]?.actions; @@ -367,7 +362,7 @@ describe("Proofreading (with mesh actions)", () => { const { annotation } = Store.getState(); const { tracingId } = annotation.volumes[0]; - const task = startSaga(function* task(): Generator { + const task = startSaga(function* task(): Saga { yield simulateSplitAgglomeratesViaMeshes(context); const mergeSaveActionBatch = context.receivedDataPerSaveRequest.at(-1)![0]?.actions; @@ -385,7 +380,7 @@ describe("Proofreading (with mesh actions)", () => { }, }, ]); - yield delay(400); + const finalMapping = yield select( (state) => getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, @@ -454,7 +449,7 @@ describe("Proofreading (with mesh actions)", () => { function* simulatePartitionedSplitAgglomeratesViaMeshes( context: WebknossosTestContext, - ): Generator { + ): Saga { const { api } = context; const { tracingId } = yield select((state: WebknossosState) => state.annotation.volumes[0]); const expectedInitialMapping = new Map([ @@ -479,8 +474,7 @@ describe("Proofreading (with mesh actions)", () => { yield put(updateSegmentAction(6, { somePosition: [1337, 1337, 1337] }, tracingId)); yield put(setActiveCellAction(6, undefined, null, 1337)); - yield call(createEditableMapping); - + yield makeMappingEditableHelper(); // After making the mapping editable, it should not have changed (as no other user did any update actions in between). const mapping1 = yield select( (state) => @@ -527,7 +521,7 @@ describe("Proofreading (with mesh actions)", () => { const { annotation } = Store.getState(); const { tracingId } = annotation.volumes[0]; - const task = startSaga(function* task(): Generator { + const task = startSaga(function* task(): Saga { yield simulatePartitionedSplitAgglomeratesViaMeshes(context); const mergeSaveActionBatch = context.receivedDataPerSaveRequest.at(-1)![0]?.actions; @@ -635,7 +629,7 @@ describe("Proofreading (with mesh actions)", () => { const { annotation } = Store.getState(); const { tracingId } = annotation.volumes[0]; - const task = startSaga(function* task(): Generator { + const task = startSaga(function* task(): Saga { yield simulatePartitionedSplitAgglomeratesViaMeshes(context); const mergeSaveActionBatch = context.receivedDataPerSaveRequest.at(-1)![0]?.actions; diff --git a/frontend/javascripts/test/sagas/proofreading/proofreading_multi_user.spec.ts b/frontend/javascripts/test/sagas/proofreading/proofreading_multi_user.spec.ts index 8c4c1dadbcb..c3a75c3a9e9 100644 --- a/frontend/javascripts/test/sagas/proofreading/proofreading_multi_user.spec.ts +++ b/frontend/javascripts/test/sagas/proofreading/proofreading_multi_user.spec.ts @@ -11,9 +11,8 @@ import { setActiveCellAction, updateSegmentAction, } from "viewer/model/actions/volumetracing_actions"; -import { select } from "viewer/model/sagas/effect-generators"; +import { type Saga, select } from "viewer/model/sagas/effect-generators"; import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; -import { createEditableMapping } from "viewer/model/sagas/volume/proofread_saga"; import { Store } from "viewer/singletons"; import { type NumberLike, startSaga } from "viewer/store"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -24,6 +23,7 @@ import { } from "./proofreading_fixtures"; import { initializeMappingAndTool, + makeMappingEditableHelper, mockInitialBucketAndAgglomerateData, } from "./proofreading_test_utils"; import type { NeighborInfo } from "admin/rest_api"; @@ -77,7 +77,7 @@ describe("Proofreading (Multi User)", () => { yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); yield put(setActiveCellAction(1)); - yield call(createEditableMapping); + yield makeMappingEditableHelper(); // After making the mapping editable, it should not have changed (as no other user did any update actions in between). const mapping1 = yield select( @@ -159,7 +159,7 @@ describe("Proofreading (Multi User)", () => { yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); yield put(setActiveCellAction(1)); - yield call(createEditableMapping); + yield makeMappingEditableHelper(); // After making the mapping editable, it should not have changed (as no other user did any update actions in between). const mapping1 = yield select( (state) => @@ -262,7 +262,7 @@ describe("Proofreading (Multi User)", () => { function* performCutFromAllNeighbours( context: WebknossosTestContext, tracingId: string, - ): Generator { + ): Saga { yield call(initializeMappingAndTool, context, tracingId); const mapping0 = yield select( (state) => @@ -275,7 +275,7 @@ describe("Proofreading (Multi User)", () => { yield put(updateSegmentAction(2, { somePosition: [2, 2, 2] }, tracingId)); yield put(setActiveCellAction(2)); - yield call(createEditableMapping); + yield makeMappingEditableHelper(); // After making the mapping editable, it should not have changed (as no other user did any update actions in between). const mapping1 = yield select( (state) => @@ -446,7 +446,7 @@ describe("Proofreading (Multi User)", () => { yield put(updateSegmentAction(3, { somePosition: [3, 3, 3] }, tracingId)); yield put(setActiveCellAction(3)); - yield call(createEditableMapping); + yield makeMappingEditableHelper(); // After making the mapping editable, it should not have changed (as no other user did any update actions in between). const mapping1 = yield select( (state) => @@ -544,7 +544,7 @@ describe("Proofreading (Multi User)", () => { yield put(updateSegmentAction(4, { somePosition: [4, 4, 4] }, tracingId)); yield put(setActiveCellAction(4)); - yield call(createEditableMapping); + yield makeMappingEditableHelper(); // After making the mapping editable, it should not have changed (as no other user did any update actions in between). const mapping1 = yield select( (state) => @@ -707,7 +707,7 @@ describe("Proofreading (Multi User)", () => { yield put(updateSegmentAction(4, { somePosition: [4, 4, 4] }, tracingId)); yield put(setActiveCellAction(4)); - yield call(createEditableMapping); + yield makeMappingEditableHelper(); // After making the mapping editable, it should not have changed (as no other user did any update actions in between). const mapping1 = yield select( (state) => @@ -798,7 +798,7 @@ describe("Proofreading (Multi User)", () => { yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); yield put(setActiveCellAction(1)); - yield call(createEditableMapping); + yield makeMappingEditableHelper(); yield put(setOthersMayEditForAnnotationAction(true)); // Execute the actual merge and wait for the finished mapping. yield put( diff --git a/frontend/javascripts/test/sagas/proofreading/proofreading_single_user.spec.ts b/frontend/javascripts/test/sagas/proofreading/proofreading_single_user.spec.ts index 97905ff1236..38063d35e34 100644 --- a/frontend/javascripts/test/sagas/proofreading/proofreading_single_user.spec.ts +++ b/frontend/javascripts/test/sagas/proofreading/proofreading_single_user.spec.ts @@ -21,8 +21,12 @@ import { } from "./proofreading_fixtures"; import { initializeMappingAndTool, + makeMappingEditableHelper, mockInitialBucketAndAgglomerateData, } from "./proofreading_test_utils"; +import { loadAgglomerateSkeletons } from "./proofreading_skeleton_test_utils"; +import { TreeTypeEnum } from "viewer/constants"; +import { getTreesWithType } from "viewer/model/accessors/skeletontracing_accessor"; describe("Proofreading (Single User)", () => { beforeEach(async (context) => { @@ -121,7 +125,7 @@ describe("Proofreading (Single User)", () => { ); // Execute the split and wait for the finished mapping. - yield put(minCutAgglomerateWithPositionAction([2, 2, 2], 2, 10)); + yield put(minCutAgglomerateWithPositionAction([2, 2, 2], 2, 1)); yield take("FINISH_MAPPING_INITIALIZATION"); const mapping1 = yield select( @@ -150,4 +154,234 @@ describe("Proofreading (Single User)", () => { await task.toPromise(); }, 8000); + + it("should merge two agglomerates and update the mapping and agglomerate skeleton accordingly", async (context: WebknossosTestContext) => { + const { api } = context; + mockInitialBucketAndAgglomerateData(context); + + const { annotation } = Store.getState(); + const { tracingId } = annotation.volumes[0]; + + const task = startSaga(function* () { + yield call(initializeMappingAndTool, context, tracingId); + + // Set up the merge-related segment partners. Normally, this would happen + // due to the user's interactions. + yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); + yield put(setActiveCellAction(1)); + yield makeMappingEditableHelper(); + + // Restore original parsing of tracings to make the mocked agglomerate skeleton implementation work. + vi.mocked(context.mocks.parseProtoTracing).mockRestore(); + yield* loadAgglomerateSkeletons(context, [1, 4, 6], false, false); + + // Execute the actual merge and wait for the finished mapping. + yield put(proofreadMergeAction([4, 4, 4], 1)); + // Wait till while proofreading action is finished including agglomerate skeleton refresh + yield take("SET_BUSY_BLOCKING_INFO_ACTION"); // Turning busy state on + yield take("SET_BUSY_BLOCKING_INFO_ACTION"); // and off when finished + + yield call(() => api.tracing.save()); + + const updatedAgglomerateTrees = yield* select((state) => + getTreesWithType(state.annotation.skeleton!, TreeTypeEnum.AGGLOMERATE), + ); + expect(updatedAgglomerateTrees.size()).toBe(2); + expect(updatedAgglomerateTrees.getOrThrow(3).nodes.size()).toBe(5); + const allNodes = Array.from(updatedAgglomerateTrees.getOrThrow(3).nodes.values()); + const allPositionsSorted = allNodes + .map((n) => n.untransformedPosition) + .sort((a, b) => a[0] - b[0]); + expect(allPositionsSorted).toStrictEqual([ + [1, 1, 1], + [2, 2, 2], + [3, 3, 3], + [4, 4, 4], + [5, 5, 5], + ]); + + const agglomerateSkletonReloadingUpdates = context.receivedDataPerSaveRequest.at(-1)!; + yield expect(agglomerateSkletonReloadingUpdates).toMatchFileSnapshot( + "./__snapshots__/proofreading_single_user.spec.ts/merge_should_refresh_agglomerate_skeletons.json", + ); + }); + + await task.toPromise(); + }); + + it("should merge two agglomerates and update not update agglomerate skeleton if not included in update actions.", async (context: WebknossosTestContext) => { + const { api } = context; + mockInitialBucketAndAgglomerateData(context); + + const { annotation } = Store.getState(); + const { tracingId } = annotation.volumes[0]; + + const task = startSaga(function* () { + yield call(initializeMappingAndTool, context, tracingId); + + // Set up the merge-related segment partners. Normally, this would happen + // due to the user's interactions. + yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); + yield put(setActiveCellAction(1)); + yield makeMappingEditableHelper(); + + // Restore original parsing of tracings to make the mocked agglomerate skeleton implementation work. + vi.mocked(context.mocks.parseProtoTracing).mockRestore(); + yield* loadAgglomerateSkeletons(context, [6], true, false); + + // Execute the actual merge and wait for the finished mapping. + yield put(proofreadMergeAction([4, 4, 4], 1)); + // Wait till while proofreading action is finished including agglomerate skeleton refresh + yield take("SET_BUSY_BLOCKING_INFO_ACTION"); // Turning busy state on + yield take("SET_BUSY_BLOCKING_INFO_ACTION"); // and off when finished + + yield call(() => api.tracing.save()); + + const updatedAgglomerateTrees = yield* select((state) => + getTreesWithType(state.annotation.skeleton!, TreeTypeEnum.AGGLOMERATE), + ); + expect(updatedAgglomerateTrees.size()).toBe(1); + expect(updatedAgglomerateTrees.getOrThrow(3).nodes.size()).toBe(2); + const allNodes = Array.from(updatedAgglomerateTrees.getOrThrow(3).nodes.values()); + const allPositionsSorted = allNodes + .map((n) => n.untransformedPosition) + .sort((a, b) => a[0] - b[0]); + expect(allPositionsSorted).toStrictEqual([ + [6, 6, 6], + [7, 7, 7], + ]); + + const agglomerateSkeletonUpdateActions = context.receivedDataPerSaveRequest + .at(-1)! + .filter((batch) => + batch.actions.some((action) => + ["deleteTree", "updateActiveNode", "createTree", "createNode", "createEdge"].includes( + action.name, + ), + ), + ); + expect(agglomerateSkeletonUpdateActions.length).toBe(0); + }); + + await task.toPromise(); + }); + + it("should split an agglomerate and update the mapping and agglomerate skeleton accordingly", async (context: WebknossosTestContext) => { + const { api } = context; + mockInitialBucketAndAgglomerateData(context); + + const { annotation } = Store.getState(); + const { tracingId } = annotation.volumes[0]; + + const task = startSaga(function* () { + yield call(initializeMappingAndTool, context, tracingId); + + // Set up the split-related segment partners. Normally, this would happen + // due to the user's interactions. + yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); + yield put(setActiveCellAction(1)); + yield makeMappingEditableHelper(); + + // Restore original parsing of tracings to make the mocked agglomerate skeleton implementation work. + vi.mocked(context.mocks.parseProtoTracing).mockRestore(); + yield* loadAgglomerateSkeletons(context, [1], false, false); + + // Prepare the server's reply for the upcoming split. + vi.mocked(context.mocks.getEdgesForAgglomerateMinCut).mockReturnValue( + Promise.resolve([ + { + position1: [1, 1, 1], + position2: [2, 2, 2], + segmentId1: 1, + segmentId2: 2, + }, + ]), + ); + + // Execute the split and wait for the finished mapping. + yield put(minCutAgglomerateWithPositionAction([2, 2, 2], 2, 1)); + // Wait till while proofreading action is finished including agglomerate skeleton refresh + yield take("SET_BUSY_BLOCKING_INFO_ACTION"); // Turning busy state on + yield take("SET_BUSY_BLOCKING_INFO_ACTION"); // and off when finished + + yield call(() => api.tracing.save()); + + const updatedAgglomerateTrees = yield* select((state) => + getTreesWithType(state.annotation.skeleton!, TreeTypeEnum.AGGLOMERATE), + ); + expect(updatedAgglomerateTrees.size()).toBe(2); + expect(updatedAgglomerateTrees.getOrThrow(3).nodes.size()).toBe(1); // TODO fix-> Id is not present + expect(updatedAgglomerateTrees.getOrThrow(4).nodes.size()).toBe(2); + + const agglomerateSkletonReloadingUpdates = context.receivedDataPerSaveRequest.at(-1)!; + yield expect(agglomerateSkletonReloadingUpdates).toMatchFileSnapshot( + "./__snapshots__/proofreading_single_user.spec.ts/split_should_refresh_agglomerate_skeletons.json", + ); + }); + + await task.toPromise(); + }); + + it("should split an agglomerate and not update an unaffected agglomerate skeleton", async (context: WebknossosTestContext) => { + const { api } = context; + mockInitialBucketAndAgglomerateData(context); + + const { annotation } = Store.getState(); + const { tracingId } = annotation.volumes[0]; + + const task = startSaga(function* () { + yield call(initializeMappingAndTool, context, tracingId); + + // Set up the split-related segment partners. Normally, this would happen + // due to the user's interactions. + yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); + yield put(setActiveCellAction(1)); + yield makeMappingEditableHelper(); + + // Restore original parsing of tracings to make the mocked agglomerate skeleton implementation work. + vi.mocked(context.mocks.parseProtoTracing).mockRestore(); + yield* loadAgglomerateSkeletons(context, [4], false, false); + + // Prepare the server's reply for the upcoming split. + vi.mocked(context.mocks.getEdgesForAgglomerateMinCut).mockReturnValue( + Promise.resolve([ + { + position1: [1, 1, 1], + position2: [2, 2, 2], + segmentId1: 1, + segmentId2: 2, + }, + ]), + ); + + // Execute the split and wait for the finished mapping. + yield put(minCutAgglomerateWithPositionAction([2, 2, 2], 2, 1)); + // Wait till while proofreading action is finished including agglomerate skeleton refresh + yield take("SET_BUSY_BLOCKING_INFO_ACTION"); // Turning busy state on + yield take("SET_BUSY_BLOCKING_INFO_ACTION"); // and off when finished + + yield call(() => api.tracing.save()); + + const updatedAgglomerateTrees = yield* select((state) => + getTreesWithType(state.annotation.skeleton!, TreeTypeEnum.AGGLOMERATE), + ); + expect(updatedAgglomerateTrees.size()).toBe(1); + expect(updatedAgglomerateTrees.getOrThrow(3).nodes.size()).toBe(2); + const allNodes = Array.from(updatedAgglomerateTrees.getOrThrow(3).nodes.values()); + const allPositionsSorted = allNodes + .map((n) => n.untransformedPosition) + .sort((a, b) => a[0] - b[0]); + expect(allPositionsSorted).toStrictEqual([ + [4, 4, 4], + [5, 5, 5], + ]); + + const agglomerateSkletonReloadingUpdates = context.receivedDataPerSaveRequest.at(-1)!; + yield expect(agglomerateSkletonReloadingUpdates).toMatchFileSnapshot( + "./__snapshots__/proofreading_single_user.spec.ts/split_should_not_refresh_unaffected_agglomerate_skeletons.json", + ); + }); + + await task.toPromise(); + }); }); diff --git a/frontend/javascripts/test/sagas/proofreading/proofreading_skeleton_interaction.spec.ts b/frontend/javascripts/test/sagas/proofreading/proofreading_skeleton_interaction.spec.ts index 40fabedbc14..ca9a0f94170 100644 --- a/frontend/javascripts/test/sagas/proofreading/proofreading_skeleton_interaction.spec.ts +++ b/frontend/javascripts/test/sagas/proofreading/proofreading_skeleton_interaction.spec.ts @@ -13,9 +13,8 @@ import { setActiveCellAction, updateSegmentAction, } from "viewer/model/actions/volumetracing_actions"; -import { select } from "viewer/model/sagas/effect-generators"; +import { type Saga, select } from "viewer/model/sagas/effect-generators"; import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; -import { createEditableMapping } from "viewer/model/sagas/volume/proofread_saga"; import { Store } from "viewer/singletons"; import { type NumberLike, @@ -27,13 +26,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { expectedMappingAfterMerge, initialMapping } from "./proofreading_fixtures"; import { initializeMappingAndTool, + makeMappingEditableHelper, mockInitialBucketAndAgglomerateData, } from "./proofreading_test_utils"; +import { loadAgglomerateSkeletons } from "./proofreading_skeleton_test_utils"; function* performMergeTreesProofreading( context: WebknossosTestContext, shouldSaveAfterLoadingTrees: boolean, -): Generator { +): Saga { const { api } = context; const { tracingId } = yield select((state: WebknossosState) => state.annotation.volumes[0]); yield call(initializeMappingAndTool, context, tracingId); @@ -46,8 +47,7 @@ function* performMergeTreesProofreading( // due to the user's interactions. yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); yield put(setActiveCellAction(1)); - - yield call(createEditableMapping); + yield makeMappingEditableHelper(); // After making the mapping editable, it should not have changed (as no other user did any update actions in between). const mapping1 = yield select( @@ -55,34 +55,17 @@ function* performMergeTreesProofreading( ); expect(mapping1).toEqual(initialMapping); yield put(setOthersMayEditForAnnotationAction(true)); - // Restore original parsing of tracings to make the mocked agglomerate skeleton implementation work. - vi.mocked(context.mocks.parseProtoTracing).mockRestore(); - yield call(loadAgglomerateSkeletonAtPosition, [1, 1, 1]); - // Wait until skeleton saga has loaded the skeleton. - yield take("ADD_TREES_AND_GROUPS"); - yield call(loadAgglomerateSkeletonAtPosition, [4, 4, 4]); - // Wait until skeleton saga has loaded the skeleton. - yield take("ADD_TREES_AND_GROUPS"); - if (shouldSaveAfterLoadingTrees) { - yield call(() => api.tracing.save()); // Also pulls newest version from backend. - } - const skeletonWithAgglomerateTrees: SkeletonTracing = yield select( - (state: WebknossosState) => state.annotation.skeleton, + const agglomerateTrees = yield* loadAgglomerateSkeletons( + context, + [1, 4], + shouldSaveAfterLoadingTrees, + true, ); - const agglomerateTrees = Array.from( - skeletonWithAgglomerateTrees.trees - .values() - .filter((tree) => tree.type === TreeTypeEnum.AGGLOMERATE), - ); - expect(agglomerateTrees.length).toBe(2); - const sourceNode = agglomerateTrees[0].nodes.getOrThrow(6); - expect(sourceNode.untransformedPosition).toStrictEqual([3, 3, 3]); - const targetNode = agglomerateTrees[1].nodes.getOrThrow(7); - expect(targetNode.untransformedPosition).toStrictEqual([4, 4, 4]); - yield put(mergeTreesAction(sourceNode.id, targetNode.id)); + const sourceNode = agglomerateTrees.getOrThrow(3).nodes.getOrThrow(6); + const targetNode = agglomerateTrees.getOrThrow(4).nodes.getOrThrow(7); + yield put(mergeTreesAction(sourceNode.id, targetNode.id)); yield take("FINISH_MAPPING_INITIALIZATION"); - const mappingAfterOptimisticUpdate = yield select( (state) => getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, ); @@ -92,7 +75,7 @@ function* performMergeTreesProofreading( } // Loads agglomerate tree for agglomerate 1 and splits segments 2 and 3. -function* performSplitTreesProofreading(context: WebknossosTestContext): Generator { +function* performSplitTreesProofreading(context: WebknossosTestContext): Saga { const { api } = context; const { tracingId } = yield select((state: WebknossosState) => state.annotation.volumes[0]); yield call(initializeMappingAndTool, context, tracingId); @@ -106,7 +89,7 @@ function* performSplitTreesProofreading(context: WebknossosTestContext): Generat yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); yield put(setActiveCellAction(1)); - yield call(createEditableMapping); + yield makeMappingEditableHelper(); // After making the mapping editable, it should not have changed (as no other user did any update actions in between). const mapping1 = yield select( @@ -116,23 +99,9 @@ function* performSplitTreesProofreading(context: WebknossosTestContext): Generat yield put(setOthersMayEditForAnnotationAction(true)); // Restore original parsing of tracings to make the mocked agglomerate skeleton implementation work. vi.mocked(context.mocks.parseProtoTracing).mockRestore(); - yield call(loadAgglomerateSkeletonAtPosition, [1, 1, 1]); - // Wait until skeleton saga has loaded the skeleton. - yield take("ADD_TREES_AND_GROUPS"); - yield call(() => api.tracing.save()); // Also pulls newest version from backend. - const skeletonWithAgglomerateTrees: SkeletonTracing = yield select( - (state: WebknossosState) => state.annotation.skeleton, - ); - const agglomerateTrees = Array.from( - skeletonWithAgglomerateTrees.trees - .values() - .filter((tree) => tree.type === TreeTypeEnum.AGGLOMERATE), - ); - expect(agglomerateTrees.length).toBe(1); - const sourceNode = agglomerateTrees[0].nodes.getOrThrow(5); - expect(sourceNode.untransformedPosition).toStrictEqual([2, 2, 2]); - const targetNode = agglomerateTrees[0].nodes.getOrThrow(6); - expect(targetNode.untransformedPosition).toStrictEqual([3, 3, 3]); + const agglomerateTrees = yield* loadAgglomerateSkeletons(context, [1], true, true); + const sourceNode = agglomerateTrees.getOrThrow(3).nodes.getOrThrow(5); + const targetNode = agglomerateTrees.getOrThrow(3).nodes.getOrThrow(6); yield put(deleteEdgeAction(sourceNode.id, targetNode.id)); yield take("FINISH_MAPPING_INITIALIZATION"); @@ -140,9 +109,7 @@ function* performSplitTreesProofreading(context: WebknossosTestContext): Generat yield call(() => api.tracing.save()); // Also pulls newest version from backend. } -function* performMinCutWithNodesProofreading( - context: WebknossosTestContext, -): Generator { +function* performMinCutWithNodesProofreading(context: WebknossosTestContext): Saga { const { api } = context; const { tracingId } = yield select((state: WebknossosState) => state.annotation.volumes[0]); yield call(initializeMappingAndTool, context, tracingId); @@ -156,7 +123,7 @@ function* performMinCutWithNodesProofreading( yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); yield put(setActiveCellAction(1)); - yield call(createEditableMapping); + yield makeMappingEditableHelper(); // After making the mapping editable, it should not have changed (as no other user did any update actions in between). const mapping1 = yield select( @@ -247,6 +214,43 @@ describe("Proofreading (With Agglomerate Skeleton interactions)", () => { expect(hasRootSagaCrashed()).toBe(false); }); + it("should mock loading agglomerate skeletons correctly", async (context: WebknossosTestContext) => { + const _backendMock = mockInitialBucketAndAgglomerateData(context); + const task = startSaga(function* task() { + const { tracingId } = yield select((state: WebknossosState) => state.annotation.volumes[0]); + yield call(initializeMappingAndTool, context, tracingId); + // Set up the merge-related segment partners. Normally, this would happen + // due to the user's interactions. + yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); + yield put(setActiveCellAction(1)); + + yield makeMappingEditableHelper(); + + // Restore original parsing of tracings to make the mocked agglomerate skeleton implementation work. + vi.mocked(context.mocks.parseProtoTracing).mockRestore(); + yield call(loadAgglomerateSkeletonAtPosition, [1, 1, 1]); + // Wait until skeleton saga has loaded the skeleton. + yield take("ADD_TREES_AND_GROUPS"); + yield call(loadAgglomerateSkeletonAtPosition, [4, 4, 4]); + // Wait until skeleton saga has loaded the skeleton. + yield take("ADD_TREES_AND_GROUPS"); + const skeletonWithAgglomerateTrees: SkeletonTracing = yield select( + (state: WebknossosState) => state.annotation.skeleton, + ); + const agglomerateTrees = Array.from( + skeletonWithAgglomerateTrees.trees + .values() + .filter((tree) => tree.type === TreeTypeEnum.AGGLOMERATE), + ); + expect(agglomerateTrees.length).toBe(2); + const sourceNode = agglomerateTrees[0].nodes.getOrThrow(6); + expect(sourceNode.untransformedPosition).toStrictEqual([3, 3, 3]); + const targetNode = agglomerateTrees[1].nodes.getOrThrow(7); + expect(targetNode.untransformedPosition).toStrictEqual([4, 4, 4]); + }); + await task.toPromise(); + }); + it("performMergeTreesProofreading should apply correct update actions after loading agglomerate trees", async (context: WebknossosTestContext) => { const _backendMock = mockInitialBucketAndAgglomerateData(context); @@ -275,7 +279,7 @@ describe("Proofreading (With Agglomerate Skeleton interactions)", () => { agglomerateId2: 6, }, }; - backendMock.planVersionInjection(7, [injectedMerge]); + backendMock.planVersionInjection(9, [injectedMerge]); const { annotation } = Store.getState(); const { tracingId } = annotation.volumes[0]; @@ -284,10 +288,9 @@ describe("Proofreading (With Agglomerate Skeleton interactions)", () => { const shouldSaveAfterLoadingTrees = false; yield performMergeTreesProofreading(context, shouldSaveAfterLoadingTrees); // This includes the create agglomerate tree & merge agglomerate tree update actions. - console.error("requests length", context.receivedDataPerSaveRequest.length); - const injectedMergeRequest = context.receivedDataPerSaveRequest.at(2)![0]; + const injectedMergeRequest = context.receivedDataPerSaveRequest.at(4)![0]; expect(injectedMergeRequest.actions).toEqual([injectedMerge]); - expect(injectedMergeRequest.version).toEqual(7); + expect(injectedMergeRequest.version).toEqual(9); // Includes loading of agglomerate trees and tree merging operation & agglomerate merge update const latestUpdateActionRequestPayload = context.receivedDataPerSaveRequest.at(-1)!; yield expect(latestUpdateActionRequestPayload).toMatchFileSnapshot( @@ -336,7 +339,7 @@ describe("Proofreading (With Agglomerate Skeleton interactions)", () => { yield performMergeTreesProofreading(context, shouldSaveAfterLoadingTrees); // This includes the create agglomerate tree actions. console.error("requests length", context.receivedDataPerSaveRequest.length); - const injectedMergeRequest = context.receivedDataPerSaveRequest.at(3)![0]; + const injectedMergeRequest = context.receivedDataPerSaveRequest.at(4)![0]; expect(injectedMergeRequest.actions).toEqual([injectedMerge]); expect(injectedMergeRequest.version).toEqual(9); // Should include agglomerate tree updates & mergeAgglomerate action. @@ -388,7 +391,7 @@ describe("Proofreading (With Agglomerate Skeleton interactions)", () => { const shouldSaveAfterLoadingTrees = true; yield performMergeTreesProofreading(context, shouldSaveAfterLoadingTrees); console.error("requests length", context.receivedDataPerSaveRequest.length); - const injectedMergeRequest = context.receivedDataPerSaveRequest.at(3)![0]; + const injectedMergeRequest = context.receivedDataPerSaveRequest.at(4)![0]; expect(injectedMergeRequest.actions).toEqual([injectedMerge]); expect(injectedMergeRequest.version).toEqual(9); // This includes the create agglomerate tree & merge agglomerate tree update actions. diff --git a/frontend/javascripts/test/sagas/proofreading/proofreading_skeleton_test_utils.ts b/frontend/javascripts/test/sagas/proofreading/proofreading_skeleton_test_utils.ts index 551559abd3e..2e5880a73d4 100644 --- a/frontend/javascripts/test/sagas/proofreading/proofreading_skeleton_test_utils.ts +++ b/frontend/javascripts/test/sagas/proofreading/proofreading_skeleton_test_utils.ts @@ -6,9 +6,14 @@ import type { } from "types/api_types"; import { Root } from "protobufjs"; -import type { TreeType } from "viewer/constants"; +import { TreeTypeEnum, type TreeType } from "viewer/constants"; import { PROTO_FILES, PROTO_TYPES } from "viewer/model/helpers/proto_helpers"; -import type { Edge } from "viewer/model/types/tree_types"; +import type { Edge, TreeMap } from "viewer/model/types/tree_types"; +import type { WebknossosTestContext } from "test/helpers/apiHelpers"; +import { loadAgglomerateSkeletonAtPosition } from "viewer/controller/combinations/segmentation_handlers"; +import { vi } from "vitest"; +import { type Saga, take, call, select } from "viewer/model/sagas/effect-generators"; +import { getTreesWithType } from "viewer/model/accessors/skeletontracing_accessor"; export function encodeServerTracing( tracing: ServerTracing, @@ -37,20 +42,11 @@ export function encodeServerTracing( * @param tracingId id for the resulting tracing */ export function createSkeletonTracingFromAdjacency( - adjacencyList: Array<[number, number]>, + adjacencyList: Map>, startNode: number, tracingId: string, version: number, ): ServerSkeletonTracing { - // Build adjacency map (undirected) - const adj = new Map>(); - for (const [a, b] of adjacencyList) { - if (!adj.has(a)) adj.set(a, new Set()); - if (!adj.has(b)) adj.set(b, new Set()); - adj.get(a)!.add(b); - adj.get(b)!.add(a); - } - // BFS to find component containing startNode const visited = new Set(); const queue: number[] = [startNode]; @@ -59,7 +55,7 @@ export function createSkeletonTracingFromAdjacency( while (queue.length) { const n = queue.shift()!; - const neighbours = adj.get(n); + const neighbours = adjacencyList.get(n); if (!neighbours) continue; for (const nb of neighbours) { if (!visited.has(nb)) { @@ -75,9 +71,18 @@ export function createSkeletonTracingFromAdjacency( const componentNodes = Array.from(visited).sort((a, b) => a - b); const componentNodeSet = new Set(componentNodes); - const componentEdges: Edge[] = adjacencyList - .filter(([a, b]) => componentNodeSet.has(a) && componentNodeSet.has(b) && a !== b) - .map(([a, b]) => ({ source: a, target: b })); + const componentEdges: Edge[] = []; + for (const [node, neighbours] of adjacencyList) { + for (const neighbour of neighbours) { + if ( + componentNodeSet.has(node) && + componentNodeSet.has(neighbour) && + !componentEdges.some((e) => e.source === neighbour && e.target === node) + ) { + componentEdges.push({ source: node, target: neighbour }); + } + } + } // Build ServerNode objects. Position = (n,n,n) as requested. const now = Date.now(); @@ -141,3 +146,33 @@ export function createSkeletonTracingFromAdjacency( return tracing; } + +// Little helper to load a list of agglomerate skeletons in a test. +// Should be done before any other mapping changes. Else the assumptions of the tests are not correct. +// The agglomerate ids must correspond to one of the agglomerate positions. +// Should be the case initially for all proofreading tests. +export function* loadAgglomerateSkeletons( + context: WebknossosTestContext, + agglomerateIdsToLoad: number[], + shouldSaveAfterLoadingTrees: boolean, + isInLiveCollabMode: boolean, +): Saga { + // Restore original parsing of tracings to make the mocked agglomerate skeleton implementation work. + vi.mocked(context.mocks.parseProtoTracing).mockRestore(); + for (let index = 0; index < agglomerateIdsToLoad.length; ++index) { + const agglomerateId = agglomerateIdsToLoad[index]; + yield call(loadAgglomerateSkeletonAtPosition, [agglomerateId, agglomerateId, agglomerateId]); + // Wait until skeleton saga has loaded the skeleton. + if (isInLiveCollabMode) { + yield take("DONE_SAVING"); + } else { + yield take("ADD_TREES_AND_GROUPS"); + } + } + if (shouldSaveAfterLoadingTrees) { + yield call(() => context.api.tracing.save()); // Also pulls newest version from backend. + } + return yield* select((state) => + getTreesWithType(state.annotation.skeleton!, TreeTypeEnum.AGGLOMERATE), + ); +} diff --git a/frontend/javascripts/test/sagas/proofreading/proofreading_test_utils.ts b/frontend/javascripts/test/sagas/proofreading/proofreading_test_utils.ts index dcb063052bd..e6880d956fd 100644 --- a/frontend/javascripts/test/sagas/proofreading/proofreading_test_utils.ts +++ b/frontend/javascripts/test/sagas/proofreading/proofreading_test_utils.ts @@ -17,7 +17,7 @@ import { AnnotationTool } from "viewer/model/accessors/tool_accessor"; import { setZoomStepAction } from "viewer/model/actions/flycam_actions"; import { setActiveOrganizationAction } from "viewer/model/actions/organization_actions"; import { setMappingAction } from "viewer/model/actions/settings_actions"; -import { setToolAction } from "viewer/model/actions/ui_actions"; +import { setBusyBlockingInfoAction, setToolAction } from "viewer/model/actions/ui_actions"; import type { Saga } from "viewer/model/sagas/effect-generators"; import { select } from "viewer/model/sagas/effect-generators"; import type { @@ -31,6 +31,8 @@ import { createSkeletonTracingFromAdjacency, encodeServerTracing, } from "./proofreading_skeleton_test_utils"; +import { createEditableMapping } from "viewer/model/sagas/volume/proofreading/proofread_saga"; +import { delay } from "typed-redux-saga"; export function* initializeMappingAndTool( context: WebknossosTestContext, @@ -289,12 +291,22 @@ class BackendMock { _tracingId: string, agglomerateId: number, ): Promise => { + // Does not currently support versioning as this would require a versioned adjacency list. const version = this.agglomerateMapping.currentVersion; + const adjacencyList = this.agglomerateMapping.getAdjacencyList(); const mapping = this.agglomerateMapping.getMap(version).entries().toArray(); - // TODOM: createSkeletonTracingFromAdjacency expects an unmapped id and not an agglomerateId + const someSegmentOfAgglomerate = mapping.find( + ([_segment, agglomerate]) => agglomerate === agglomerateId, + ); + if (!someSegmentOfAgglomerate) { + throw new Error( + `Could not find any segment pointing to agglomerate with id ${agglomerateId}!`, + ); + } + const segmentId = someSegmentOfAgglomerate[0]; const agglomerateSkeletonAsServerTracing = createSkeletonTracingFromAdjacency( - mapping, - agglomerateId, + adjacencyList, + segmentId, "agglomerateSkeleton", version, ); @@ -349,3 +361,20 @@ export function mockInitialBucketAndAgglomerateData( return backendMock; } + +export function* makeMappingEditableHelper(): Saga { + // Usually the user creates an editable mapping via the first proofreading action. + // Therefore the context is busy blocked by the proofreading saga. + // As we do this manually here, we need to mock that wk is busy. + yield put(setBusyBlockingInfoAction(true, "Blocking in test for making mapping editable")); + yield call(createEditableMapping); + yield put(setBusyBlockingInfoAction(false)); + // Delay is needed to avoid the auto mapping data reloading of mapping saga to interfere with tests. + // Some tests check whether the missing agglomerate ids not present in the partial mapping in the frontend + // are actually loaded during rebasing. Such a scenario might happen when doing proofreading via meshes. + // But without the delay the mapping saga will directly replace the mapping (including the new mapping info form the rebasing) + // directly after the rebasing with a version where the additionally loaded segments are not present as they are "off screen". + // The delay gives the mapping saga time to do the update now instead of the tests directly starting the proofreading interaction and thus rebasing. + // This would delay the reloading of the partial mapping of the mapping saga, thus we wait here shortly manually. + yield delay(10); +} diff --git a/frontend/javascripts/test/sagas/save_mutex_saga.spec.ts b/frontend/javascripts/test/sagas/save_mutex_saga.spec.ts index eac730f949a..8248844b9e3 100644 --- a/frontend/javascripts/test/sagas/save_mutex_saga.spec.ts +++ b/frontend/javascripts/test/sagas/save_mutex_saga.spec.ts @@ -8,7 +8,7 @@ import { mockInitialBucketAndAgglomerateData } from "./proofreading/proofreading import { setOthersMayEditForAnnotationAction } from "viewer/model/actions/annotation_actions"; import type { ServerSkeletonTracing, ServerVolumeTracing } from "types/api_types"; import { proofreadMergeAction } from "viewer/model/actions/proofread_actions"; -import { select } from "viewer/model/sagas/effect-generators"; +import { type Saga, select } from "viewer/model/sagas/effect-generators"; import { updateSegmentAction, setActiveCellAction, @@ -85,7 +85,7 @@ function* assertMutexStoreProperties( hasAnnotationMutex: boolean, blockedByUser: any, isUpdatingCurrentlyAllowed: boolean, -): Generator { +): Saga { const hasAnnotationMutexInStore = yield select( (state) => state.save.mutexState.hasAnnotationMutex, ); diff --git a/frontend/javascripts/viewer/api/wk_dev.ts b/frontend/javascripts/viewer/api/wk_dev.ts index aed4b51075a..84732d19f68 100644 --- a/frontend/javascripts/viewer/api/wk_dev.ts +++ b/frontend/javascripts/viewer/api/wk_dev.ts @@ -11,8 +11,8 @@ import type ApiLoader from "./api_loader"; // Can be accessed via window.webknossos.DEV.flags. Only use this // for debugging or one off scripts. export const WkDevFlags = { - logActions: false, - liveCollab: false, + logActions: true, + liveCollab: true, sam: { useLocalMask: true, }, diff --git a/frontend/javascripts/viewer/geometries/skeleton.ts b/frontend/javascripts/viewer/geometries/skeleton.ts index 582f6f9c5fc..75b9d03dbc0 100644 --- a/frontend/javascripts/viewer/geometries/skeleton.ts +++ b/frontend/javascripts/viewer/geometries/skeleton.ts @@ -348,6 +348,7 @@ class Skeleton { skeletonTracing.tracingId, this.prevTracing.trees, skeletonTracing.trees, + false, ); for (const update of diff) { diff --git a/frontend/javascripts/viewer/merger_mode.ts b/frontend/javascripts/viewer/merger_mode.ts index 090476b64a6..5540a61ee1b 100644 --- a/frontend/javascripts/viewer/merger_mode.ts +++ b/frontend/javascripts/viewer/merger_mode.ts @@ -344,6 +344,7 @@ function updateState(mergerModeState: MergerModeState, skeletonTracing: Skeleton skeletonTracing.tracingId, mergerModeState.prevTracing.trees, skeletonTracing.trees, + false, ); for (const action of diff) { diff --git a/frontend/javascripts/viewer/model.ts b/frontend/javascripts/viewer/model.ts index d4912f9b04d..bc46596a1ca 100644 --- a/frontend/javascripts/viewer/model.ts +++ b/frontend/javascripts/viewer/model.ts @@ -303,7 +303,6 @@ export class WebKnossosModel { // That way, we can be sure that the diffing sagas have processed all user actions // up until the time of where waitForDifferResponses was invoked. async function waitForDifferResponses() { - console.log("waitForDifferResponses"); const { annotation } = Store.getState(); await dispatchEnsureTracingsWereDiffedToSaveQueueAction(Store.dispatch, annotation); return true; @@ -318,7 +317,6 @@ export class WebKnossosModel { // The dispatch of the saveNowAction IN the while loop is deliberate. // Otherwise if an update action is pushed to the save queue during the Utils.sleep, // the while loop would continue running until the next save would be triggered. - console.log("stuck in ensureSavedState loop"); if (!Store.getState().save.isBusy) { Store.dispatch(saveNowAction()); } diff --git a/frontend/javascripts/viewer/model/edge_collection.ts b/frontend/javascripts/viewer/model/edge_collection.ts index 6066bd9383e..2f300ebaeb1 100644 --- a/frontend/javascripts/viewer/model/edge_collection.ts +++ b/frontend/javascripts/viewer/model/edge_collection.ts @@ -164,16 +164,22 @@ export default class EdgeCollection implements NotEnumerableByObject { } // Given two EdgeCollections, this function returns an object holding: // onlyA: An array of edges, which only exists in A // onlyB: An array of edges, which only exists in B - +// If useDeepEqualityCheck is set to true, the diff might include edges which are not instance equal to on of the passed maps! +// See more details below in the if-useDeepEqualityCheck block. export function diffEdgeCollections( edgeCollectionA: EdgeCollection, edgeCollectionB: EdgeCollection, + useDeepEqualityCheck: boolean, ): { onlyA: Edge[]; onlyB: Edge[]; } { // Since inMap and outMap are symmetrical to each other, it suffices to only diff the outMaps - const mapDiff = diffDiffableMaps(edgeCollectionA.outMap, edgeCollectionB.outMap); + const mapDiff = diffDiffableMaps( + edgeCollectionA.outMap, + edgeCollectionB.outMap, + useDeepEqualityCheck, + ); const getEdgesForNodes = (nodeIds: number[], diffableMap: EdgeMap) => _.flatten(nodeIds.map((nodeId) => diffableMap.getOrThrow(nodeId))); @@ -183,13 +189,31 @@ export function diffEdgeCollections( onlyB: getEdgesForNodes(mapDiff.onlyB, edgeCollectionB.outMap), }; + // TODOM: consider tracking time needed for the whole diffing. In case it takes too long, maybe log to airbrake or so? for (const changedNodeIndex of mapDiff.changed) { // For each changedNodeIndex there is at least one outgoing edge which was added or removed. // So, check for each outgoing edge whether it only exists in A or B - const outgoingEdgesDiff = Utils.diffArrays( - edgeCollectionA.outMap.getOrThrow(changedNodeIndex), - edgeCollectionB.outMap.getOrThrow(changedNodeIndex), - ); + let outgoingEdgesDiff; + if (useDeepEqualityCheck) { + // In case of a deep equality check, diff by outgoing edges as source will always be changedNodeIndex. + // A normal implementation diff for deep edge comparison would be slow. + // The edges are then recreated based on the returned diff. + // If in some later time instance equality is needed, thi should be fairly easy to implement here. + const targetDiff = Utils.diffNumberArrays( + edgeCollectionA.outMap.getOrThrow(changedNodeIndex).map((edge) => edge.target), + edgeCollectionB.outMap.getOrThrow(changedNodeIndex).map((edge) => edge.target), + ); + outgoingEdgesDiff = { + changed: [], // unused thus ignored. + onlyA: targetDiff.onlyA.map((target) => ({ source: changedNodeIndex, target })), + onlyB: targetDiff.onlyB.map((target) => ({ source: changedNodeIndex, target })), + }; + } else { + outgoingEdgesDiff = Utils.diffArrays( + edgeCollectionA.outMap.getOrThrow(changedNodeIndex), + edgeCollectionB.outMap.getOrThrow(changedNodeIndex), + ); + } edgeDiff.onlyA = edgeDiff.onlyA.concat(outgoingEdgesDiff.onlyA); edgeDiff.onlyB = edgeDiff.onlyB.concat(outgoingEdgesDiff.onlyB); } diff --git a/frontend/javascripts/viewer/model/helpers/compaction/compact_update_actions.ts b/frontend/javascripts/viewer/model/helpers/compaction/compact_update_actions.ts index fc136d22245..90923fe1001 100644 --- a/frontend/javascripts/viewer/model/helpers/compaction/compact_update_actions.ts +++ b/frontend/javascripts/viewer/model/helpers/compaction/compact_update_actions.ts @@ -1,6 +1,7 @@ import { withoutValues } from "libs/utils"; import _ from "lodash"; import compactToggleActions from "viewer/model/helpers/compaction/compact_toggle_actions"; +import { updateNodePredicate } from "viewer/model/sagas/skeletontracing_saga"; import type { CreateEdgeUpdateAction, CreateNodeUpdateAction, @@ -137,7 +138,7 @@ function compactMovedNodesAndEdges( const newNode = tracing.trees.getNullable(newTreeId)?.nodes.getNullable(nodeId); const oldNode = prevTracing.trees.getNullable(oldTreeId)?.nodes.getNullable(nodeId); - if (newNode !== oldNode && newNode != null) { + if (newNode != null && (oldNode == null || updateNodePredicate(oldNode, newNode))) { compactedActions.push(updateNode(newTreeId, newNode, actionTracingId)); } } diff --git a/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts b/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts index f5ca052c43d..4c42c78d052 100644 --- a/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts @@ -346,6 +346,7 @@ function AnnotationReducer(state: WebknossosState, action: Action): WebknossosSt ); if (maybeMeshes == null || maybeMeshes[segmentId] == null) { // No meshes exist for the segment id. No need to do anything. + console.log("Could not find mesh", segmentId, "which was requested to be removed"); return state; } const { [segmentId]: _, ...remainingMeshes } = maybeMeshes as Record; diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/shared_update_helper.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/shared_update_helper.ts index ad4d742d97a..c6df92d5d46 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/shared_update_helper.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/shared_update_helper.ts @@ -6,6 +6,7 @@ export function withoutServerSpecificFields; diff --git a/frontend/javascripts/viewer/model/sagas/root_saga.ts b/frontend/javascripts/viewer/model/sagas/root_saga.ts index 2ad3a9ae475..1e9c720b68b 100644 --- a/frontend/javascripts/viewer/model/sagas/root_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/root_saga.ts @@ -16,7 +16,7 @@ import SkeletontracingSagas from "viewer/model/sagas/skeletontracing_saga"; import watchTasksAsync, { warnAboutMagRestriction } from "viewer/model/sagas/task_saga"; import UndoSaga from "viewer/model/sagas/undo_saga"; import MappingSaga from "viewer/model/sagas/volume/mapping_saga"; -import ProofreadSaga from "viewer/model/sagas/volume/proofread_saga"; +import ProofreadSaga from "viewer/model/sagas/volume/proofreading/proofread_saga"; import VolumetracingSagas from "viewer/model/sagas/volumetracing_saga"; import type { EscalateErrorAction } from "../actions/actions"; import { setIsWkInitializedAction } from "../actions/ui_actions"; diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_saga.tsx b/frontend/javascripts/viewer/model/sagas/saving/save_saga.tsx index 19e673f2c22..f54cdc32223 100644 --- a/frontend/javascripts/viewer/model/sagas/saving/save_saga.tsx +++ b/frontend/javascripts/viewer/model/sagas/saving/save_saga.tsx @@ -5,12 +5,34 @@ import Toast from "libs/toast"; import { sleep } from "libs/utils"; import _ from "lodash"; import { type Channel, buffers } from "redux-saga"; -import { actionChannel, call, delay, flush, fork, put, race, takeEvery } from "typed-redux-saga"; +import { + actionChannel, + call, + delay, + flush, + fork, + put, + race, + spawn, + takeEvery, +} from "typed-redux-saga"; import type { APIUpdateActionBatch } from "types/api_types"; import { WkDevFlags } from "viewer/api/wk_dev"; -import { SagaIdentifier } from "viewer/constants"; +import { SagaIdentifier, type Vector3 } from "viewer/constants"; +import { + getSegmentationLayerByName, + getVisibleSegmentationLayer, +} from "viewer/model/accessors/dataset_accessor"; +import { + getSegmentsForLayer, + getVolumeTracingById, +} from "viewer/model/accessors/volumetracing_accessor"; import type { Action } from "viewer/model/actions/actions"; -import { showManyBucketUpdatesWarningAction } from "viewer/model/actions/annotation_actions"; +import { + removeMeshAction, + showManyBucketUpdatesWarningAction, +} from "viewer/model/actions/annotation_actions"; +import { ensureLayerMappingsAreLoadedAction } from "viewer/model/actions/dataset_actions"; import { type EnsureHasNewestVersionAction, type NotifyAboutUpdatedBucketsAction, @@ -22,7 +44,11 @@ import { } from "viewer/model/actions/save_actions"; import { setMappingAction } from "viewer/model/actions/settings_actions"; import { applySkeletonUpdateActionsFromServerAction } from "viewer/model/actions/skeletontracing_actions"; -import { applyVolumeUpdateActionsFromServerAction } from "viewer/model/actions/volumetracing_actions"; +import { + applyVolumeUpdateActionsFromServerAction, + setHasEditableMappingAction, + setMappingIsLockedAction, +} from "viewer/model/actions/volumetracing_actions"; import { globalPositionToBucketPositionWithMag } from "viewer/model/helpers/position_converter"; import type { Saga } from "viewer/model/sagas/effect-generators"; import { select, take } from "viewer/model/sagas/effect-generators"; @@ -33,7 +59,12 @@ import { enforceExecutionAsBusyBlockingUnlessAllowed, takeEveryWithBatchActionSupport, } from "../saga_helpers"; -import { splitAgglomerateInMapping, updateMappingWithMerge } from "../volume/proofread_saga"; +import { + coarselyLoadedSegmentIds as ProofreadSaga_LoadedProofreadingMeshIds, + refreshAffectedMeshes, + splitAgglomerateInMapping, + updateMappingWithMerge, +} from "../volume/proofreading/proofread_saga"; import { saveQueueEntriesToServerUpdateActionBatches, updateSaveQueueEntriesToStateAfterRebase, @@ -383,6 +414,13 @@ export function* tryToIncorporateActions( yield* call(fn); } } + // Tracks which agglomerate ids were changed of which the frontend has loaded meshes to assist proofreading. + // Maps from the old agglomerate id to a potentially new one. + // Duplicates are later ignored when refreshing the meshes. + const activeVolumeTracingId = (yield* select(getVisibleSegmentationLayer))?.tracingId; + const agglomerateIdsWithOutdatedMeshes = new Set(); + const agglomerateIdsToReloadMeshesFor = new Set(); + for (const actionBatch of newerActions) { const agglomerateIdsToRefresh = new Set(); let volumeTracingIdOfMapping = null; @@ -513,6 +551,23 @@ export function* tryToIncorporateActions( agglomerateId2, !areUnsavedChangesOfUser, ); + if ( + (!ProofreadSaga_LoadedProofreadingMeshIds.has(agglomerateId1) && + !ProofreadSaga_LoadedProofreadingMeshIds.has(agglomerateId2)) || + activeVolumeTracingId !== actionTracingId || + areUnsavedChangesOfUser + ) { + break; + } + // agglomerateId2 is merged into agglomerateId1 and the frontend currently has at least one of the meshes loaded. + // Outdate agglomerateId1 and agglomerateId2. Only agglomerateId1 needs to be reloaded however. + // Track outdated and updated agglomerateIds to refresh after applying updates. + + agglomerateIdsWithOutdatedMeshes.add(agglomerateId1); + agglomerateIdsWithOutdatedMeshes.add(agglomerateId2); + // Remove refresh entry of agglomerateId2 as it was merged into agglomerateId1. + agglomerateIdsToReloadMeshesFor.delete(agglomerateId2); + agglomerateIdsToReloadMeshesFor.add(agglomerateId1); break; } case "splitAgglomerate": { @@ -542,10 +597,39 @@ export function* tryToIncorporateActions( yield* call(finalize); return { success: false }; } - break; } + case "updateMappingName": { + // TODO migrate to applyVolumeUpdateActionsFromServerAction. + // Refactor mapping activation first before implementing this. + const { actionTracingId, mappingName, isEditable, isLocked } = action.value; + let mappingType = undefined; + if (mappingName) { + let volumeDataLayer = yield* select((state) => + getSegmentationLayerByName(state.dataset, actionTracingId), + ); + if (volumeDataLayer.mappings == null || volumeDataLayer.agglomerates == null) { + yield* put(ensureLayerMappingsAreLoadedAction(actionTracingId)); + yield* take("SET_LAYER_MAPPINGS"); + } + mappingType = + (volumeDataLayer.agglomerates ?? []).indexOf(mappingName) >= 0 + ? ("HDF5" as const) + : ("JSON" as const); + } + yield* put(setMappingAction(actionTracingId, mappingName, mappingType, true)); + const volume = yield* select((state) => + getVolumeTracingById(state.annotation, actionTracingId), + ); + if (!volume.hasEditableMapping && isEditable) { + yield* put(setHasEditableMappingAction(actionTracingId)); + } + if (!volume.mappingIsLocked && isLocked) { + yield* put(setMappingIsLockedAction(actionTracingId)); + } + break; + } /* * Currently NOT supported: */ @@ -563,7 +647,6 @@ export function* tryToIncorporateActions( // Volume case "removeFallbackLayer": - case "updateMappingName": // Refactor mapping activation first before implementing this. // Legacy! The following actions are legacy actions and don't // need to be supported. @@ -590,14 +673,24 @@ export function* tryToIncorporateActions( const activeMapping = yield* select( (store) => store.temporaryConfiguration.activeMappingByLayer[volumeTracingIdOfMapping], ); - const splitMapping = yield* splitAgglomerateInMapping( + const splitMappingInfo = yield* splitAgglomerateInMapping( activeMapping, // TODO: Add 64 bit support Number(agglomerateIdToRefresh), volumeTracingIdOfMapping, actionBatch.version, + false, ); + if (splitMappingInfo == null) { + const message = + "Failed to apply split mapping action from other user. Please refresh the page to resync and loose as less of your work as possible."; + console.error(message); + Toast.error(message); + return { success: false }; + } + const { splitMapping, oldAgglomerateIds, newAgglomerateIds } = splitMappingInfo; + yield* put( setMappingAction( volumeTracingIdOfMapping, @@ -611,9 +704,63 @@ export function* tryToIncorporateActions( }, ), ); + + const loadedProofreadingAuxiliaryMeshesOfSplitAction = + ProofreadSaga_LoadedProofreadingMeshIds.intersection(oldAgglomerateIds); + if (loadedProofreadingAuxiliaryMeshesOfSplitAction.size > 0) { + oldAgglomerateIds.forEach((aggloId) => agglomerateIdsWithOutdatedMeshes.add(aggloId)); + newAgglomerateIds.forEach((aggloId) => agglomerateIdsToReloadMeshesFor.add(aggloId)); + } } } + + if (activeVolumeTracingId) { + // Remove all outdated meshes. + let someAgglomerateIdToRemove; + console.log("Start removing outdated meshes", ...Array.from(agglomerateIdsWithOutdatedMeshes)); + for (const aggloId of agglomerateIdsWithOutdatedMeshes) { + yield* put(removeMeshAction(activeVolumeTracingId, Number(aggloId))); + if (!someAgglomerateIdToRemove) { + someAgglomerateIdToRemove = aggloId; + } + } + console.log( + "Finished removing outdated meshes", + ...Array.from(agglomerateIdsWithOutdatedMeshes), + ); + if (!someAgglomerateIdToRemove) { + someAgglomerateIdToRemove = 0; + } + // construct refreshAffectedMeshes parameters. + // As all outdated meshes were already removed, someAgglomerateIdToRemove can be used in every refresh list item. + // TODOM + const refreshList: Array<{ + agglomerateId: number; + newAgglomerateId: number; + nodePosition: Vector3; + }> = []; + const { hasSegmentIndex } = yield* select((state) => + getVolumeTracingById(state.annotation, activeVolumeTracingId), + ); + const segments = yield* select((state) => getSegmentsForLayer(state, activeVolumeTracingId)); + + for (const agglomerateId of agglomerateIdsToReloadMeshesFor) { + const segmentPosition = segments.getNullable(agglomerateId)?.somePosition; + // If the annotation has a segment index, the seed position for the mesh generation is ignored. In that case we can simply use [0, 0, 0]. + if (segmentPosition || hasSegmentIndex) { + refreshList.push({ + agglomerateId: someAgglomerateIdToRemove, + newAgglomerateId: agglomerateId, + nodePosition: segmentPosition ?? [0, 0, 0], + }); + } + } + console.log("Start refreshing segments", refreshList); + yield* spawn(refreshAffectedMeshes, activeVolumeTracingId, refreshList); + console.log("Finished refreshing segments", refreshList); + } yield* call(finalize); return { success: true }; } + export default [setupSavingToServer, watchForNewerAnnotationVersion]; diff --git a/frontend/javascripts/viewer/model/sagas/skeletontracing_saga.ts b/frontend/javascripts/viewer/model/sagas/skeletontracing_saga.ts index b1d797eaef3..68b80de9e92 100644 --- a/frontend/javascripts/viewer/model/sagas/skeletontracing_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/skeletontracing_saga.ts @@ -80,9 +80,13 @@ import { updateTreeGroupsExpandedState, updateTreeVisibility, } from "viewer/model/sagas/volume/update_actions"; -import { api } from "viewer/singletons"; +import { Model, api } from "viewer/singletons"; import type { SkeletonTracing, WebknossosState } from "viewer/store"; import Store from "viewer/store"; +import { + dispatchEnsureHasAnnotationMutexAsync, + dispatchEnsureHasNewestVersionAsync, +} from "../actions/save_actions"; import { diffBoundingBoxes, diffGroups } from "../helpers/diff_helpers"; import { eulerAngleToReducerInternalMatrix, @@ -91,6 +95,7 @@ import { import type { MutableNode, Node, NodeMap, Tree, TreeMap } from "../types/tree_types"; import { ensureWkInitialized } from "./ready_sagas"; import { takeWithBatchActionSupport } from "./saga_helpers"; +import { MutexFetchingStrategy, getCurrentMutexFetchingStrategy } from "./saving/save_mutex_saga"; function getNodeRotationWithoutPlaneRotation(activeNode: Readonly): Vector3 { // In orthogonal view mode, the active planes' default rotation is added to the flycam rotation upon node creation. @@ -276,7 +281,7 @@ export function* watchConnectomeAgglomerateLoading(): Saga { ); } -function* getAgglomerateSkeletonTracing( +export function* getAgglomerateSkeletonTracing( layerName: string, mappingName: string, agglomerateId: number, @@ -414,22 +419,46 @@ export function* loadAgglomerateSkeletonWithId( ); let usedTreeIds: number[] | null = null; + let agglomerateSkeleton: ServerSkeletonTracing; + const othersMayEdit = yield* select((state) => state.annotation.othersMayEdit); + const shouldGuardWithAnnotationMutex = + othersMayEdit && (yield* call(getCurrentMutexFetchingStrategy)) === MutexFetchingStrategy.AdHoc; try { - const parsedTracing = yield* call( - getAgglomerateSkeletonTracing, - layerName, - mappingName, - agglomerateId, - ); + if (shouldGuardWithAnnotationMutex) { + yield* call(dispatchEnsureHasAnnotationMutexAsync, Store.dispatch); + + // Fetch agglomerate skeleton in parallel to updating to latest version to make syncing with the server faster. + // We already sync here to make the save after adding the agglomerate skeleton a fast forward like update, + // which then only sends the whole save queue to the server. + const { parsedTracing } = yield* all({ + updateToLatestVersion: call(dispatchEnsureHasNewestVersionAsync, Store.dispatch), + parsedTracing: call(getAgglomerateSkeletonTracing, layerName, mappingName, agglomerateId), + }); + agglomerateSkeleton = parsedTracing; + } else { + agglomerateSkeleton = yield* call( + getAgglomerateSkeletonTracing, + layerName, + mappingName, + agglomerateId, + ); + } + yield* put( addTreesAndGroupsAction( - createMutableTreeMapFromTreeArray(parsedTracing.trees), - parsedTracing.treeGroups, + createMutableTreeMapFromTreeArray(agglomerateSkeleton.trees), + agglomerateSkeleton.treeGroups, (newTreeIds) => { usedTreeIds = newTreeIds; }, ), ); + if (shouldGuardWithAnnotationMutex) { + // Enforces to directly store the loaded agglomerate skeleton to the annotation on the server to enable easier syncing of update actions. + // The saving includes releasing the mutex acquired earlier. + yield* call([Model, Model.ensureSavedState]); + } + // @ts-ignore TS infers usedTreeIds to be never, but it should be number[] if its not null if (usedTreeIds == null || usedTreeIds.length !== 1) { throw new Error( @@ -437,6 +466,7 @@ export function* loadAgglomerateSkeletonWithId( ); } } catch (e) { + // TODOM: release mutex // Hide the progress notification and handle the error hideFn(); // @ts-ignore @@ -518,13 +548,14 @@ function* diffNodes( prevNodes: NodeMap, nodes: NodeMap, treeId: number, + useDeepEqualityCheck: boolean, ): Generator { if (prevNodes === nodes) return; const { onlyA: deletedNodeIds, onlyB: addedNodeIds, changed: changedNodeIds, - } = diffDiffableMaps(prevNodes, nodes); + } = diffDiffableMaps(prevNodes, nodes, useDeepEqualityCheck); for (const nodeId of deletedNodeIds) { yield deleteNode(treeId, nodeId, tracingId); @@ -545,7 +576,7 @@ function* diffNodes( } } -function updateNodePredicate(prevNode: Node, node: Node): boolean { +export function updateNodePredicate(prevNode: Node, node: Node): boolean { return !_.isEqual(prevNode, node); } @@ -554,9 +585,14 @@ function* diffEdges( prevEdges: EdgeCollection, edges: EdgeCollection, treeId: number, + useDeepEqualityCheck: boolean, ): Generator { if (prevEdges === edges) return; - const { onlyA: deletedEdges, onlyB: addedEdges } = diffEdgeCollections(prevEdges, edges); + const { onlyA: deletedEdges, onlyB: addedEdges } = diffEdgeCollections( + prevEdges, + edges, + useDeepEqualityCheck, + ); for (const edge of deletedEdges) { yield deleteEdge(treeId, edge.source, edge.target, tracingId); @@ -567,47 +603,55 @@ function* diffEdges( } } -function updateTreePredicate(prevTree: Tree, tree: Tree): boolean { - return ( - // branchPoints and comments are arrays and therefore checked for - // equality. This avoids unnecessary updates in certain cases (e.g., - // when two trees are merged, the comments are concatenated, even - // if one of them is empty; thus, resulting in new instances). - !_.isEqual(prevTree.branchPoints, tree.branchPoints) || - !_.isEqual(prevTree.comments, tree.comments) || - prevTree.color !== tree.color || - prevTree.name !== tree.name || - prevTree.timestamp !== tree.timestamp || - prevTree.groupId !== tree.groupId || - prevTree.type !== tree.type || - prevTree.metadata !== tree.metadata - ); +function updateTreePredicate(prevTree: Tree, tree: Tree, useDeepEqualityCheck: boolean): boolean { + return useDeepEqualityCheck + ? // branchPoints and comments are arrays and therefore checked for + // equality. This avoids unnecessary updates in certain cases (e.g., + // when two trees are merged, the comments are concatenated, even + // if one of them is empty; thus, resulting in new instances). + !_.isEqual(prevTree.branchPoints, tree.branchPoints) || + !_.isEqual(prevTree.comments, tree.comments) || + !_.isEqual(prevTree.color, tree.color) || + prevTree.name !== tree.name || + prevTree.timestamp !== tree.timestamp || + prevTree.groupId !== tree.groupId || + prevTree.type !== tree.type || + !_.isEqual(prevTree.metadata, tree.metadata) + : !_.isEqual(prevTree.branchPoints, tree.branchPoints) || + !_.isEqual(prevTree.comments, tree.comments) || + prevTree.color !== tree.color || + prevTree.name !== tree.name || + prevTree.timestamp !== tree.timestamp || + prevTree.groupId !== tree.groupId || + prevTree.type !== tree.type || + prevTree.metadata !== tree.metadata; } export function* diffTrees( tracingId: string, prevTrees: TreeMap, trees: TreeMap, + useDeepEqualityCheck: boolean, ): Generator { if (prevTrees === trees) return; const { changed: bothTreeIds, onlyA: deletedTreeIds, onlyB: addedTreeIds, - } = diffDiffableMaps(prevTrees, trees); + } = diffDiffableMaps(prevTrees, trees, useDeepEqualityCheck); for (const treeId of deletedTreeIds) { const prevTree = prevTrees.getOrThrow(treeId); - yield* diffNodes(tracingId, prevTree.nodes, new DiffableMap(), treeId); - yield* diffEdges(tracingId, prevTree.edges, new EdgeCollection(), treeId); + yield* diffNodes(tracingId, prevTree.nodes, new DiffableMap(), treeId, useDeepEqualityCheck); + yield* diffEdges(tracingId, prevTree.edges, new EdgeCollection(), treeId, useDeepEqualityCheck); yield deleteTree(treeId, tracingId); } for (const treeId of addedTreeIds) { const tree = trees.getOrThrow(treeId); yield createTree(tree, tracingId); - yield* diffNodes(tracingId, new DiffableMap(), tree.nodes, treeId); - yield* diffEdges(tracingId, new EdgeCollection(), tree.edges, treeId); + yield* diffNodes(tracingId, new DiffableMap(), tree.nodes, treeId, useDeepEqualityCheck); + yield* diffEdges(tracingId, new EdgeCollection(), tree.edges, treeId, useDeepEqualityCheck); } for (const treeId of bothTreeIds) { @@ -615,10 +659,10 @@ export function* diffTrees( const prevTree: Tree = prevTrees.getOrThrow(treeId); if (tree !== prevTree) { - yield* diffNodes(tracingId, prevTree.nodes, tree.nodes, treeId); - yield* diffEdges(tracingId, prevTree.edges, tree.edges, treeId); + yield* diffNodes(tracingId, prevTree.nodes, tree.nodes, treeId, useDeepEqualityCheck); + yield* diffEdges(tracingId, prevTree.edges, tree.edges, treeId, useDeepEqualityCheck); - if (updateTreePredicate(prevTree, tree)) { + if (updateTreePredicate(prevTree, tree, useDeepEqualityCheck)) { yield updateTree(tree, tracingId); } @@ -632,13 +676,15 @@ export function* diffTrees( } } -export const cachedDiffTrees = memoizeOne((tracingId: string, prevTrees: TreeMap, trees: TreeMap) => - Array.from(diffTrees(tracingId, prevTrees, trees)), +export const cachedDiffTrees = memoizeOne( + (tracingId: string, prevTrees: TreeMap, trees: TreeMap, useDeepEqualityCheck: boolean) => + Array.from(diffTrees(tracingId, prevTrees, trees, useDeepEqualityCheck)), ); export function* diffSkeletonTracing( prevSkeletonTracing: SkeletonTracing, skeletonTracing: SkeletonTracing, + useDeepEqualityCheck: boolean = false, ): Generator { if (prevSkeletonTracing === skeletonTracing) { return; @@ -647,6 +693,7 @@ export function* diffSkeletonTracing( skeletonTracing.tracingId, prevSkeletonTracing.trees, skeletonTracing.trees, + useDeepEqualityCheck, ); const groupDiff = diffGroups(prevSkeletonTracing.treeGroups, skeletonTracing.treeGroups); diff --git a/frontend/javascripts/viewer/model/sagas/volume/proofreading/agglomerate_skeleton_syncing_saga_helpers.ts b/frontend/javascripts/viewer/model/sagas/volume/proofreading/agglomerate_skeleton_syncing_saga_helpers.ts new file mode 100644 index 00000000000..05b68cfc204 --- /dev/null +++ b/frontend/javascripts/viewer/model/sagas/volume/proofreading/agglomerate_skeleton_syncing_saga_helpers.ts @@ -0,0 +1,287 @@ +import DiffableMap from "libs/diffable_map"; +import { getAdaptToTypeFunction } from "libs/utils"; +import { all, put } from "typed-redux-saga"; +import { TreeTypeEnum, type Vector3 } from "viewer/constants"; +import { + enforceSkeletonTracing, + findTreeByName, + getTreeNameForAgglomerateSkeleton, + getTreesWithType, +} from "viewer/model/accessors/skeletontracing_accessor"; +import { applySkeletonUpdateActionsFromServerAction } from "viewer/model/actions/skeletontracing_actions"; +import EdgeCollection from "viewer/model/edge_collection"; +import { + createMutableTreeMapFromTreeArray, + getMaximumTreeId, +} from "viewer/model/reducers/skeletontracing_reducer_helpers"; +import { type Tree, TreeMap } from "viewer/model/types/tree_types"; +import type { Node } from "viewer/model/types/tree_types"; +import type { NumberLikeMap, SkeletonTracing } from "viewer/store"; +import { type Saga, call, select } from "../../effect-generators"; +import { diffSkeletonTracing, getAgglomerateSkeletonTracing } from "../../skeletontracing_saga"; +import { + type ApplicableSkeletonServerUpdateAction, + ApplicableSkeletonUpdateActionNamesHelperNamesList, + type UpdateActionWithoutIsolationRequirement, +} from "../update_actions"; + +type ActionSegmentInfo = { + agglomerateId: number; + unmappedId: number; + position: Vector3; +}; + +function getAgglomerateTreeIfExists( + agglomerateId: number, + mappingName: string, + trees: TreeMap, +): Tree | undefined { + const agglomerateTreeName = getTreeNameForAgglomerateSkeleton(agglomerateId, mappingName); + return findTreeByName(trees, agglomerateTreeName); +} + +// Puts the given trees into a skeleton tracing object, where the value equal those of the active skeleton tracing. +// Before calling, ensure a skeleton tracing exists. +function* agglomerateTreesToSkeleton(trees: Tree[]): Saga { + const skeletonTracing = yield* select((state) => enforceSkeletonTracing(state.annotation)); + let treeMapWithOldAggloTrees = new TreeMap(); + for (const tree of trees) { + treeMapWithOldAggloTrees = treeMapWithOldAggloTrees.set(tree.treeId, tree); + } + const tracingWitTreesReplaced = { + ...skeletonTracing, + trees: treeMapWithOldAggloTrees, + }; + return tracingWitTreesReplaced; +} + +function* getAgglomerateTreesAsSkeleton(agglomerateIds: number[], mappingName: string) { + const skeletonTracing = yield* select((state) => state.annotation.skeleton); + if (!skeletonTracing) { + return; + } + const trees = yield* select((state) => + getTreesWithType(enforceSkeletonTracing(state.annotation), TreeTypeEnum.AGGLOMERATE), + ); + if (mappingName == null) { + return; + } + const existingAgglomerateTrees = agglomerateIds + .map((aggloId) => getAgglomerateTreeIfExists(aggloId, mappingName, trees)) + .filter((tree) => tree != null); + if (existingAgglomerateTrees.length === 0) { + return; + } + const tracingWithOldAggloTrees = yield* agglomerateTreesToSkeleton(existingAgglomerateTrees); + return tracingWithOldAggloTrees; +} + +function* getAllAgglomerateTreesFromServerAndRemap( + agglomerateIds: number[], + positionToIdMap: PositionToIdMap, + treeIds: number[], + tracingId: string, + mappingName: string, +): Saga { + const skeletonTracing = yield* select((state) => enforceSkeletonTracing(state.annotation)); + const loadTreeEffects = agglomerateIds.map((id) => + call(getAgglomerateSkeletonTracing, tracingId, mappingName, id), + ); + const aggloTreeTracings = yield* all(loadTreeEffects); + const aggloTrees = aggloTreeTracings.reduce((aggloTrees, tracing, index) => { + const treesOfTracing = Array.from(createMutableTreeMapFromTreeArray(tracing.trees).values()); + const remappedTrees = remapNodeIdsWithPositionMap( + treesOfTracing, + positionToIdMap, + skeletonTracing, + ); + remappedTrees[0] = { ...remappedTrees[0], treeId: treeIds[index] }; + return aggloTrees.concat(remappedTrees); + }, [] as Tree[]); + const tracingWithNewAggloTreesFromServer = yield* agglomerateTreesToSkeleton(aggloTrees); + return tracingWithNewAggloTreesFromServer; +} + +// This function creates an object mapping from a position (stringified) to a node id. +// The goal is to be able to remap the node ids of the refreshed agglomerate skeleton to keep +// the changes made to the skeleton minimal. Therefore, the active node id stays the same. +// Resulting in not deactivating different nodes when syncing agglomerate skeletons with a non-skeleton based proofreading action. +type PositionToIdMap = Record; +function createPositionToIdMap(trees: MapIterator) { + const positionToIdMap: Record = {}; + for (const tree of trees) { + for (const node of tree.nodes.values()) { + positionToIdMap[node.untransformedPosition.toString()] = node.id; + } + } + return positionToIdMap; +} + +// TODOM: write tests for this as this can be pretty complex. +function remapNodeIdsWithPositionMap( + trees: Tree[], + positionToIdMap: PositionToIdMap, + skeletonTracing: SkeletonTracing, +): Tree[] { + let newNodeId = skeletonTracing.cachedMaxNodeId; + const getNextFreeNodeId = () => ++newNodeId; + return trees.map((tree) => { + let updatedNodes = new DiffableMap(); + let updatedEdges = new EdgeCollection(); + const remappedIds: Record = {}; + for (const node of tree.nodes.values()) { + const newId = positionToIdMap[node.untransformedPosition.toString()] ?? getNextFreeNodeId(); + const updatedNode = { + ...node, + id: newId, + } as Node; + updatedNodes = updatedNodes.set(newId, updatedNode); + remappedIds[node.id] = newId; + } + for (const edge of tree.edges.values()) { + const edgeWithUpdatedIds = { + source: remappedIds[edge.source], + target: remappedIds[edge.target], + }; + updatedEdges = updatedEdges.addEdge(edgeWithUpdatedIds); + } + + return { + ...tree, + nodes: updatedNodes, + edges: updatedEdges, + }; + }); +} + +function deepDiffSkeletonTracings( + prevSkeleton: SkeletonTracing, + newSkeletonWithUpdatedIds: SkeletonTracing, +): UpdateActionWithoutIsolationRequirement[] { + const newSkeletonWithCorrectProps = { + ...prevSkeleton, + trees: newSkeletonWithUpdatedIds.trees, + }; + return Array.from(diffSkeletonTracing(prevSkeleton, newSkeletonWithCorrectProps, true)); +} + +export function* syncAgglomerateSkeletonsAfterMergeAction( + tracingId: string, + sourceInfo: ActionSegmentInfo, + targetInfo: ActionSegmentInfo, +): Saga { + const activeMapping = yield* select( + (store) => store.temporaryConfiguration.activeMappingByLayer[tracingId], + ); + const { mappingName } = activeMapping; + if (mappingName == null) { + return; + } + const tracingWithOldAggloTrees = yield* call( + getAgglomerateTreesAsSkeleton, + [sourceInfo.agglomerateId, targetInfo.agglomerateId], + mappingName, + ); + if (!tracingWithOldAggloTrees) { + return; + } + const positionToIdMap = createPositionToIdMap(tracingWithOldAggloTrees.trees.values()); + + const adaptToType = getAdaptToTypeFunction(activeMapping.mapping); + const updatedSourceAgglomerateId = Number( + (activeMapping.mapping as NumberLikeMap | undefined)?.get(adaptToType(sourceInfo.unmappedId)) ?? + sourceInfo.agglomerateId, + ); + const maybeSourceAgglomerateTree = getAgglomerateTreeIfExists( + sourceInfo.agglomerateId, + mappingName, + tracingWithOldAggloTrees.trees, + ); + const assignedTreeIds = maybeSourceAgglomerateTree + ? [maybeSourceAgglomerateTree.treeId] + : [getMaximumTreeId(tracingWithOldAggloTrees.trees) + 1]; + + const updatedAgglomerateSkeleton = yield* call( + getAllAgglomerateTreesFromServerAndRemap, + [updatedSourceAgglomerateId], + positionToIdMap, + assignedTreeIds, + tracingId, + mappingName, + ); + + const diffActions = deepDiffSkeletonTracings( + tracingWithOldAggloTrees, + updatedAgglomerateSkeleton, + ); + const diffActionsWithMissingServerFields = diffActions + .filter((a) => ApplicableSkeletonUpdateActionNamesHelperNamesList.includes(a.name)) + .map( + (a) => + ({ + ...a, + value: { + ...a.value, + actionTimestamp: 0, // ignored anyway + actionAuthorId: "me", + } as const, + }) as const, + ) as ApplicableSkeletonServerUpdateAction[]; + + yield* put(applySkeletonUpdateActionsFromServerAction(diffActionsWithMissingServerFields)); +} + +export function* syncAgglomerateSkeletonsAfterSplitAction( + oldAgglomerateIds: number[], + newAgglomerateIds: number[], + tracingId: string, + mappingName: string, +): Saga { + if (mappingName == null) { + return; + } + const tracingWithOldAggloTrees = yield* call( + getAgglomerateTreesAsSkeleton, + oldAgglomerateIds, + mappingName, + ); + if (!tracingWithOldAggloTrees) { + return; + } + const positionToIdMap = createPositionToIdMap(tracingWithOldAggloTrees.trees.values()); + + let newTreeId = getMaximumTreeId(tracingWithOldAggloTrees.trees) + 1; + + const assignedTreeIds = newAgglomerateIds + .map((id) => getAgglomerateTreeIfExists(id, mappingName, tracingWithOldAggloTrees.trees)) + .map((tree) => (tree ? tree.treeId : newTreeId++)); + + const updatedAgglomerateSkeleton = yield* call( + getAllAgglomerateTreesFromServerAndRemap, + newAgglomerateIds, + positionToIdMap, + assignedTreeIds, + tracingId, + mappingName, + ); + + const diffActions = deepDiffSkeletonTracings( + tracingWithOldAggloTrees, + updatedAgglomerateSkeleton, + ); + const diffActionsWithMissingServerFields = diffActions + .filter((a) => ApplicableSkeletonUpdateActionNamesHelperNamesList.includes(a.name)) + .map( + (a) => + ({ + ...a, + value: { + ...a.value, + actionTimestamp: 0, // ignored anyway + actionAuthorId: "me", + } as const, + }) as const, + ) as ApplicableSkeletonServerUpdateAction[]; + + yield* put(applySkeletonUpdateActionsFromServerAction(diffActionsWithMissingServerFields)); +} diff --git a/frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts b/frontend/javascripts/viewer/model/sagas/volume/proofreading/proofread_saga.ts similarity index 93% rename from frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts rename to frontend/javascripts/viewer/model/sagas/volume/proofreading/proofread_saga.ts index 07e14a4c63d..895e2fa60a8 100644 --- a/frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/volume/proofreading/proofread_saga.ts @@ -90,11 +90,15 @@ import { } from "viewer/model/sagas/volume/update_actions"; import { Model, Store, api } from "viewer/singletons"; import type { ActiveMappingInfo, Mapping, NumberLikeMap, VolumeTracing } from "viewer/store"; -import { getCurrentMag } from "../../accessors/flycam_accessor"; -import type { Action } from "../../actions/actions"; -import type { Tree } from "../../types/tree_types"; -import { ensureWkInitialized } from "../ready_sagas"; -import { takeEveryUnlessBusy, takeWithBatchActionSupport } from "../saga_helpers"; +import { getCurrentMag } from "../../../accessors/flycam_accessor"; +import type { Action } from "../../../actions/actions"; +import type { Tree } from "../../../types/tree_types"; +import { ensureWkInitialized } from "../../ready_sagas"; +import { takeEveryUnlessBusy, takeWithBatchActionSupport } from "../../saga_helpers"; +import { + syncAgglomerateSkeletonsAfterMergeAction, + syncAgglomerateSkeletonsAfterSplitAction, +} from "./agglomerate_skeleton_syncing_saga_helpers"; function runSagaAndCatchSoftError(saga: (...args: any[]) => Saga) { return function* (...args: any[]) { @@ -138,6 +142,7 @@ export default function* proofreadRootSaga(): Saga { ); yield* takeEveryUnlessBusy( ["CUT_AGGLOMERATE_FROM_NEIGHBORS"], + // TODO: fiddly to keep in sync with agglomerate skeletons. runSagaAndCatchSoftError(handleProofreadCutFromNeighbors), PROOFREADING_BUSY_REASON, ); @@ -210,7 +215,7 @@ function* syncWithBackend() { yield* put(disallowSagaWhileBusyAction(SagaIdentifier.SAVE_SAGA)); } -let coarselyLoadedSegmentIds: number[] = []; +export const coarselyLoadedSegmentIds = new Set(); // TODOM function* loadCoarseMesh( layerName: string, @@ -278,7 +283,8 @@ function* loadCoarseMesh( ); } - coarselyLoadedSegmentIds.push(segmentId); + coarselyLoadedSegmentIds.add(segmentId); + console.log("coarselyLoadedSegmentIds", coarselyLoadedSegmentIds); } function* checkForAgglomerateSkeletonModification( @@ -316,10 +322,10 @@ function* proofreadAtPosition(action: ProofreadAtPositionAction): Saga { yield put(updateProofreadingMarkerPositionAction(position, layerName)); - const segmentId = yield* call(getSegmentIdForPositionAsync, position); - if (!proofreadUsingMeshes()) return; + const segmentId = yield* call(getSegmentIdForPositionAsync, position); + /* Load a coarse ad-hoc mesh of the agglomerate at the click position */ yield* call(loadCoarseMesh, layerName, segmentId, position, additionalCoordinates); } @@ -342,7 +348,7 @@ export function* createEditableMapping(): Saga { yield* put(setMappingNameAction(layerName, volumeTracingId, "HDF5")); yield* put(setHasEditableMappingAction(volumeTracingId)); // Ensure a saved state so that the mapping is locked and editable before doing the first proofreading operation. - yield* call([Model, Model.ensureSavedState]); + yield* call(syncWithBackend); const editableMapping: ServerEditableMapping = { baseMappingName: baseMappingName, tracingId: volumeTracingId, @@ -546,12 +552,18 @@ function* handleSkeletonProofreadingAction(action: Action): Saga { // Because we ensured a saved state a few lines above, we can now split the mapping locally // as this still requires some communication with the back-end. - const splitMapping = yield* splitAgglomerateInMapping( + const splitMappingInfo = yield* splitAgglomerateInMapping( activeMapping, sourceAgglomerateId, volumeTracingId, annotationVersion, + false, ); + if (splitMappingInfo == null) { + console.error("Failed to split mapping in skeleton based proofreading action. Aborting..."); + return; + } + const { splitMapping } = splitMappingInfo; console.log("dispatch setMappingAction in proofreading saga"); yield* put( @@ -604,8 +616,8 @@ function* handleSkeletonProofreadingAction(action: Action): Saga { }); yield* spawn(refreshAffectedMeshes, volumeTracingId, [ - pack(sourceAgglomerateId, newSourceAgglomerateId, sourceNodePosition), - pack(targetAgglomerateId, newTargetAgglomerateId, targetNodePosition), + pack(sourceInfo.agglomerateId, newSourceAgglomerateId, sourceNodePosition), + pack(targetInfo.agglomerateId, newTargetAgglomerateId, targetNodePosition), ]); } @@ -760,15 +772,22 @@ function* performPartitionedMinCut(_action: MinCutPartitionsAction | EnterAction ]); // Now that the changes are saved, we can split the mapping locally (because it requires - // communication with the back-end). + // communication with the backend). const currentVersion = Store.getState().annotation.version; - const splitMapping = yield* splitAgglomerateInMapping( + + const splitMappingInfo = yield* splitAgglomerateInMapping( activeMapping, agglomerateId, volumeTracingId, currentVersion, + true, additionalUnmappedSegmentsToReRequest, ); + if (splitMappingInfo == null) { + console.error("Failed to split mapping in partitioned min cut. Aborting..."); + return; + } + const { splitMapping } = splitMappingInfo; yield* put( setMappingAction( @@ -936,7 +955,7 @@ function* clearProofreadingByproducts() { for (const segmentId of coarselyLoadedSegmentIds) { yield* put(removeMeshAction(layerName, segmentId)); } - coarselyLoadedSegmentIds = []; + coarselyLoadedSegmentIds.clear(); } const MISSING_INFORMATION_WARNING = @@ -1079,13 +1098,23 @@ function* handleProofreadMergeOrMinCut(action: Action) { const annotationVersion = yield* select((state) => state.annotation.version); // Now that the changes are saved, we can split the mapping locally (because it requires // communication with the back-end). - const splitMapping = yield* splitAgglomerateInMapping( + const splitMappingInfo = yield* splitAgglomerateInMapping( activeMapping, sourceAgglomerateId, volumeTracingId, annotationVersion, + true, ); + if (splitMappingInfo == null) { + console.error("Failed to split mapping in proofreading action. Aborting..."); + return; + } + const { splitMapping } = splitMappingInfo; + // Split agglomerate id -> vielen agglomerate ids + + // TODO: based on the split mapping, skeletons should be reloaded! + console.log("dispatch setMappingAction in proofreading saga"); yield* put( setMappingAction( @@ -1106,6 +1135,7 @@ function* handleProofreadMergeOrMinCut(action: Action) { if (action.type === "PROOFREAD_MERGE") { // Remove the segment that doesn't exist anymore. yield* put(removeSegmentAction(targetAgglomerateId, volumeTracingId)); + yield* call(syncAgglomerateSkeletonsAfterMergeAction, volumeTracingId, sourceInfo, targetInfo); } /* Reload meshes */ @@ -1157,12 +1187,12 @@ function* handleProofreadMergeOrMinCut(action: Action) { yield* spawn(refreshAffectedMeshes, volumeTracingId, [ { - agglomerateId: sourceAgglomerateId, + agglomerateId: sourceInfo.agglomerateId, newAgglomerateId: newSourceAgglomerateId, nodePosition: sourceInfo.position, }, { - agglomerateId: targetAgglomerateId, + agglomerateId: targetInfo.agglomerateId, newAgglomerateId: newTargetAgglomerateId, nodePosition: targetInfo.position, }, @@ -1252,14 +1282,20 @@ function* handleProofreadCutFromNeighbors(action: Action) { const newAnnotationVersion = yield* select((state) => state.annotation.version); // Now that the changes are saved, we can split the mapping locally (because it requires // communication with the back-end). - const mappingAfterSplit = yield* splitAgglomerateInMapping( + const splitMappingInfo = yield* splitAgglomerateInMapping( activeMapping, targetAgglomerateId, volumeTracingId, newAnnotationVersion, + true, ); - console.log("dispatch setMappingAction in proofreading saga"); + if (splitMappingInfo == null) { + console.error("Failed to split mapping in cut from all neighbors. Aborting..."); + return; + } + const { splitMapping } = splitMappingInfo; + yield* put( setMappingAction( volumeTracingId, @@ -1268,15 +1304,15 @@ function* handleProofreadCutFromNeighbors(action: Action) { // As these split actions were already sent to the server, splitMapping is stored on the server already. true, { - mapping: mappingAfterSplit, + mapping: splitMapping, }, ), ); const [newTargetAgglomerateId, ...newNeighborAgglomerateIds] = yield* all([ - call(getDataValue, targetPosition, mappingAfterSplit), + call(getDataValue, targetPosition, splitMapping), ...neighborInfo.neighbors.map((neighbor) => - call(getDataValue, neighbor.position, mappingAfterSplit), + call(getDataValue, neighbor.position, splitMapping), ), ]); @@ -1299,12 +1335,12 @@ function* handleProofreadCutFromNeighbors(action: Action) { /* Reload meshes */ yield* spawn(refreshAffectedMeshes, volumeTracingId, [ { - agglomerateId: targetAgglomerateId, + agglomerateId: idInfos[0].agglomerateId, newAgglomerateId: newTargetAgglomerateId, nodePosition: targetPosition, }, ...neighborInfo.neighbors.map((neighbor, idx) => ({ - agglomerateId: targetAgglomerateId, + agglomerateId: idInfos[0].agglomerateId, newAgglomerateId: newNeighborAgglomerateIds[idx], nodePosition: neighbor.position, })), @@ -1325,7 +1361,7 @@ type Preparation = { annotationVersion: number; }; -function* prepareSplitOrMerge(isSkeletonProofreading: boolean): Saga { +export function* prepareSplitOrMerge(isSkeletonProofreading: boolean): Saga { const volumeTracingLayer = yield* select((state) => getActiveSegmentationTracingLayer(state)); const annotationVersion = yield* select((state) => state.annotation.version); const volumeTracing = yield* select((state) => getActiveSegmentationTracing(state)); @@ -1479,7 +1515,7 @@ function* getAgglomerateInfos( } } -function* refreshAffectedMeshes( +export function* refreshAffectedMeshes( layerName: string, items: Array<{ agglomerateId: number; @@ -1590,8 +1626,12 @@ export function* splitAgglomerateInMapping( sourceAgglomerateId: number, volumeTracingId: string, version: number, + syncAgglomerateSkeletons: boolean, additionalSegmentsToRequest: number[] = [], -) { +): Saga< + | { splitMapping: Mapping; oldAgglomerateIds: Set; newAgglomerateIds: Set } + | undefined +> { const segmentIdsFromLocalMapping = getSegmentIdsThatMapToAgglomerate( activeMapping, sourceAgglomerateId, @@ -1601,8 +1641,11 @@ export function* splitAgglomerateInMapping( const tracingStoreUrl = yield* select((state) => state.annotation.tracingStore.url); // Ask the server to map the (split) segment ids. This creates a partial mapping // that only contains these ids. + const unsplitMapping = activeMapping.mapping; if (splitSegmentIds.length === 0) { - return activeMapping.mapping ?? undefined; + return unsplitMapping != null + ? { splitMapping: unsplitMapping, newAgglomerateIds: new Set(), oldAgglomerateIds: new Set() } + : undefined; } const mappingAfterSplit = yield* call( getAgglomeratesForSegmentsFromTracingstore, @@ -1612,6 +1655,20 @@ export function* splitAgglomerateInMapping( annotationId, version, ); + const oldAgglomerateIds = new Set([sourceAgglomerateId]); + if (unsplitMapping && additionalSegmentsToRequest.length > 0) { + // Add the additionally reloaded segments' agglomerate ids to the once maybe refreshed. + const adaptToType = getAdaptToTypeFunction(unsplitMapping); + additionalSegmentsToRequest.forEach((segmentId) => + oldAgglomerateIds.add( + Number( + (unsplitMapping as NumberLikeMap | undefined)?.get(adaptToType(segmentId)) ?? + sourceAgglomerateId, + ), + ), + ); + } + const newAgglomerateIds = new Set(); // Create a new mapping which is equal to the old one with the difference that // ids from splitSegmentIds are mapped to their new target agglomerate ids. @@ -1620,6 +1677,7 @@ export function* splitAgglomerateInMapping( // @ts-ignore get() is expected to accept the type that segmentId has. const mappedId = mappingAfterSplit.get(segmentId); if (mappedId != null) { + newAgglomerateIds.add(Number(mappedId)); return [segmentId, mappedId]; } return [segmentId, agglomerateId]; @@ -1630,11 +1688,21 @@ export function* splitAgglomerateInMapping( // @ts-ignore get() is expected to accept the type that unmappedId has. const mappedId = mappingAfterSplit.get(unmappedId); if (mappedId) { + newAgglomerateIds.add(Number(mappedId)); splitMapping.set(unmappedId, mappedId); } } + if (syncAgglomerateSkeletons && activeMapping.mappingName) { + yield* call( + syncAgglomerateSkeletonsAfterSplitAction, + Array.from(oldAgglomerateIds), + Array.from(newAgglomerateIds), + volumeTracingId, + activeMapping.mappingName, + ); + } - return splitMapping as Mapping; + return { splitMapping: splitMapping as Mapping, oldAgglomerateIds, newAgglomerateIds }; } function* mergeAgglomeratesInMapping( diff --git a/frontend/javascripts/viewer/model/sagas/volume/update_actions.ts b/frontend/javascripts/viewer/model/sagas/volume/update_actions.ts index e194288f5b1..3032d06e20e 100644 --- a/frontend/javascripts/viewer/model/sagas/volume/update_actions.ts +++ b/frontend/javascripts/viewer/model/sagas/volume/update_actions.ts @@ -141,6 +141,39 @@ export type WithoutServerSpecificFields } export type ApplicableSkeletonUpdateAction = WithoutServerSpecificFields; +// This helper dict exists so that we can ensure via typescript that +// the list contains all members of ApplicableSkeletonUpdateAction. As soon as +// ApplicableSkeletonUpdateAction is extended with another action, TS will complain +// if the following dictionary doesn't contain that action. +const ApplicableSkeletonUpdateActionNamesHelper: Record< + ApplicableSkeletonUpdateAction["name"], + true +> = { + updateTree: true, + createTree: true, + updateNode: true, + createNode: true, + createEdge: true, + deleteTree: true, + deleteEdge: true, + deleteNode: true, + moveTreeComponent: true, + updateTreeGroups: true, + updateTreeGroupsExpandedState: true, + updateTreeEdgesVisibility: true, + addUserBoundingBoxInSkeletonTracing: true, + updateUserBoundingBoxInSkeletonTracing: true, + updateUserBoundingBoxVisibilityInSkeletonTracing: true, + deleteUserBoundingBoxInSkeletonTracing: true, + updateActiveNode: true, + updateTreeVisibility: true, + updateTreeGroupVisibility: true, + updateActiveTree: true, +}; +export const ApplicableSkeletonUpdateActionNamesHelperNamesList = Object.keys( + ApplicableSkeletonUpdateActionNamesHelper, +); + export type ApplicableVolumeUpdateAction = | UpdateLargestSegmentIdVolumeAction | UpdateSegmentUpdateAction @@ -259,7 +292,7 @@ export function createTree(tree: Tree, actionTracingId: string) { value: { actionTracingId, id: tree.treeId, - updatedId: undefined, // was never really used, but is kept to keep the type information precise + updatedId: tree.treeId, // was never really used, but is kept to keep the type information precise color: tree.color, name: tree.name, timestamp: tree.timestamp, diff --git a/frontend/javascripts/viewer/store.ts b/frontend/javascripts/viewer/store.ts index ea461507149..509c1793f6f 100644 --- a/frontend/javascripts/viewer/store.ts +++ b/frontend/javascripts/viewer/store.ts @@ -570,6 +570,7 @@ type BaseMeshInformation = { readonly isVisible: boolean; readonly opacity: number; readonly mappingName: string | null | undefined; + readonly syncedWithVersion: number; }; export type AdHocMeshInformation = BaseMeshInformation & { readonly isPrecomputed: false; diff --git a/frontend/javascripts/viewer/view/right-border-tabs/comment_tab/comment_tab_view.tsx b/frontend/javascripts/viewer/view/right-border-tabs/comment_tab/comment_tab_view.tsx index 98a53ee8130..cbf4fd24be1 100644 --- a/frontend/javascripts/viewer/view/right-border-tabs/comment_tab/comment_tab_view.tsx +++ b/frontend/javascripts/viewer/view/right-border-tabs/comment_tab/comment_tab_view.tsx @@ -545,6 +545,7 @@ const CommentTabViewMemo = React.memo( nextProps.skeletonTracing.tracingId, prevPops.skeletonTracing.trees, nextProps.skeletonTracing.trees, + false, ), ); const relevantUpdateActions = updateActions.filter( diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala index 140d5099f4a..ab403c2bcc1 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala @@ -61,6 +61,7 @@ class DSMeshController @Inject()( dataLayer, targetMappingName, editableMappingTracingId, + request.body.editableMappingVersion, request.body.segmentId, mappingNameForMeshFile, omitMissing = false diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/DSRemoteTracingstoreClient.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/DSRemoteTracingstoreClient.scala index 60f494181b3..9bbb6290192 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/DSRemoteTracingstoreClient.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/DSRemoteTracingstoreClient.scala @@ -73,10 +73,11 @@ class DSRemoteTracingstoreClient @Inject()( def getZGroup(tracingId: String, tracingStoreUri: String)(implicit tc: TokenContext): Fox[JsObject] = rpc(s"$tracingStoreUri/tracings/volume/zarr/$tracingId/.zgroup").withTokenFromContext.getWithJsonResponse[JsObject] - def getEditableMappingSegmentIdsForAgglomerate(tracingStoreUri: String, tracingId: String, agglomerateId: Long)( + def getEditableMappingSegmentIdsForAgglomerate(tracingStoreUri: String, tracingId: String, tracingIdVersionOpt: Option[Long], agglomerateId: Long)( implicit tc: TokenContext): Fox[EditableMappingSegmentListResult] = rpc(s"$tracingStoreUri/tracings/mapping/$tracingId/segmentsForAgglomerate") .addQueryParam("agglomerateId", agglomerateId) + .addQueryParam("version", tracingIdVersionOpt) .withTokenFromContext .silent .getWithJsonResponse[EditableMappingSegmentListResult] diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/mesh/MeshFileService.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/mesh/MeshFileService.scala index c4873491047..6cc2f09e73f 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/mesh/MeshFileService.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/mesh/MeshFileService.scala @@ -19,7 +19,8 @@ import scala.concurrent.ExecutionContext case class ListMeshChunksRequest( meshFileName: String, - segmentId: Long + segmentId: Long, + editableMappingVersion: Option[Long], ) object ListMeshChunksRequest { diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/mesh/MeshMappingHelper.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/mesh/MeshMappingHelper.scala index e7a0ffeef11..61efa189f0d 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/mesh/MeshMappingHelper.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/mesh/MeshMappingHelper.scala @@ -23,6 +23,7 @@ trait MeshMappingHelper extends FoxImplicits { dataLayer: DataLayer, targetMappingName: Option[String], editableMappingTracingId: Option[String], + editableMappingVersionOpt: Option[Long], agglomerateId: Long, mappingNameForMeshFile: Option[String], omitMissing: Boolean // If true, failing lookups in the agglomerate file will just return empty list. @@ -53,6 +54,7 @@ trait MeshMappingHelper extends FoxImplicits { tracingstoreUri <- dsRemoteWebknossosClient.getTracingstoreUri segmentIdsResult <- dsRemoteTracingstoreClient.getEditableMappingSegmentIdsForAgglomerate(tracingstoreUri, tracingId, + editableMappingVersionOpt, agglomerateId) segmentIds <- if (segmentIdsResult.agglomerateIdIsPresent) Fox.successful(segmentIdsResult.segmentIds)