import { expect } from 'chai' ; import { MockContainerRuntimeFactory } from '@fluidframework/test-runtime-utils' ; import { NodeId } from '../Identifiers' ; import { ChangeNode , TraitLocation } from '../generic' ; import { StablePlace , StableRange , ConstraintEffect , revert } from '../default-edits' ; import { AnchoredChange , AnchoredDelete , AnchoredInsert , AnchoredMove , PlaceAnchor , PlaceAnchorSemanticsChoice , RangeAnchor , SharedTreeWithAnchors , } from '../anchored-edits' ; import { fail } from '../Common' ; import { makeEmptyNode , setUpTestSharedTreeWithAnchors , leftTraitLabel , rightTraitLabel , setUpLocalServerTestSharedTreeWithAnchors , } from './utilities/TestUtilities' ; import { runSharedTreeOperationsTests } from './utilities/SharedTreeTests' ; import { runSummaryTests } from './utilities/SummaryFormatCompatibilityTests' ; /**
 * This file contains tests that verify the behavior or anchors by checking how they are resolved in the face of concurrent edits.
 * There are two batteries of tests:
 * - The first is defined by the cartesian product of a representative set of concurrent edits (see `insertScenarios`) and a variety of
 *   anchor types--each anchor type is associated with an instance of `AnchorCaseOutcomes` which describe how that anchor should respond to
 *   concurrent edits (see `anchorCases`).
 * - The second checks the behavior of a specific anchor in the face of more special/corner-case concurrent edits.
 */ /**
 * The expected contents of the `parent` node's traits after a test.
 */ interface Outcome { /**
     * The expected contents of the left trait.
     * Defaults to [left].
     */ leftTrait ? : ChangeNode [ ] ; /**
     * The expected contents of the right trait.
     * Defaults to [right].
     */ rightTrait ? : ChangeNode [ ] ; } /**
 * The expected contents of the `parent` node's traits for a given anchor after each scenario.
 * Each field represents an expected outcome for a different set of concurrent edits in `insertScenarios`.
 */ interface AnchorCaseOutcomes { onNoConcurrency : Outcome ; onMove : Outcome ; onTeleport ? : Outcome ; onDelete : Outcome ; onUndoDelete ? : Outcome ; onUndoRedoDelete ? : Outcome ; onDeleteParent ? : Outcome ; onDroppedEdit : Outcome ; onInsert : Outcome ; } /**
 * An anchor with specific semantics and in a specific contexts.
 * Includes the expected outcome of inserting the `inserted` node at the given anchor for various test scenarios.
 */ interface AnchorCase extends AnchorCaseOutcomes { name : string ; insertionPlace : PlaceAnchor ; } /**
 * The changes to apply concurrently to the change whose anchor is being tested.
 */ type ConcurrentChanges = AnchoredChange | readonly AnchoredChange [ ] | ( ( tree : SharedTreeWithAnchors , anchorCase : AnchorCase ) => AnchoredChange [ ] ) ; /**
 * A scenario that an `AnchorCase` could be put through.
 */ interface TestScenario { title : string ; /**
     * Where to find the expected outcome for this scenario on a given `AnchorCase`.
     */ outcomeKey : keyof AnchorCaseOutcomes ; concurrent ? : ConcurrentChanges ; } const left = makeEmptyNode ( 'left' as NodeId ) ; const right = makeEmptyNode ( 'right' as NodeId ) ; const parent = { ... makeEmptyNode ( 'parent' as NodeId ) , traits : { [ leftTraitLabel ] : [ left ] , [ rightTraitLabel ] : [ right ] } } ; const initialTree = { ... makeEmptyNode ( 'root' as NodeId ) , traits : { parentTraitLabel : [ parent ] } } ; const leftTraitLocation = { parent : parent . identifier , label : leftTraitLabel } ; const rightTraitLocation = { parent : parent . identifier , label : rightTraitLabel } ; /**
 * Used for tests that require more than the left and right nodes.
 */ const extra = makeEmptyNode ( 'extra' as NodeId ) ; /**
 * The node inserted by the change whose anchors are being tested.
 * Tests outcomes are decided based on where this node ends up (if present at all).
 */ const inserted = makeEmptyNode ( 'inserted' as NodeId ) ; /**
 * Used for tests cases where we check `inserted`'s final location with respect to another node that is concurrently inserted.
 */ const concurrentlyInserted = makeEmptyNode ( 'concurrently inserted' as NodeId ) ; const treeOptions = { initialTree , localMode : false , enableAnchors : true , allowInvalid : true } ; const secondTreeOptions = { id : 'secondTestSharedTree' , localMode : false , enableAnchors : true , allowInvalid : true } ; const thirdTreeOptions = { id : 'thirdTestSharedTree' , localMode : false , enableAnchors : true , allowInvalid : true } ; const boundToNodeBefore = { name : 'BoundToNode Before(left)' , insertionPlace : PlaceAnchor . before ( left , PlaceAnchorSemanticsChoice . BoundToNode ) , onNoConcurrency : { leftTrait : [ inserted , left ] } , onMove : { leftTrait : [ ] , rightTrait : [ inserted , left , right ] } , onDelete : { leftTrait : [ ] } , onDroppedEdit : { leftTrait : [ inserted , left ] } , onInsert : { leftTrait : [ concurrentlyInserted , inserted , left ] } } ; const boundToNodeAfter = { ... boundToNodeBefore , name : 'BoundToNode After(left)' , insertionPlace : PlaceAnchor . after ( left , PlaceAnchorSemanticsChoice . BoundToNode ) , onNoConcurrency : { leftTrait : [ left , inserted ] } , onMove : { leftTrait : [ ] , rightTrait : [ left , inserted , right ] } , onDroppedEdit : { leftTrait : [ left , inserted ] } , onInsert : { leftTrait : [ left , inserted , concurrentlyInserted ] } } ; const boundToNodeStart = { ... boundToNodeBefore , name : 'BoundToNode Start(left trait)' , insertionPlace : PlaceAnchor . atStartOf ( leftTraitLocation , PlaceAnchorSemanticsChoice . BoundToNode ) , onMove : { leftTrait : [ inserted ] , rightTrait : [ left , right ] } , onDelete : { leftTrait : [ inserted ] } , onInsert : { leftTrait : [ inserted , concurrentlyInserted , left ] } } ; const boundToNodeEnd = { ... boundToNodeAfter , name : 'BoundToNode End(left trait)' , insertionPlace : PlaceAnchor . atEndOf ( leftTraitLocation , PlaceAnchorSemanticsChoice . BoundToNode ) , onMove : { leftTrait : [ inserted ] , rightTrait : [ left , right ] } , onDelete : { leftTrait : [ inserted ] } , onInsert : { leftTrait : [ left , concurrentlyInserted , inserted ] } } ; const relativeToNodeBefore = { ... boundToNodeBefore , name : 'RelativeToNode Before(left)' , insertionPlace : PlaceAnchor . before ( left , PlaceAnchorSemanticsChoice . RelativeToNode ) , onDelete : { leftTrait : [ inserted ] } } ; const relativeToNodeAfter = { ... boundToNodeAfter , name : 'RelativeToNode After(left)' , insertionPlace : PlaceAnchor . after ( left , PlaceAnchorSemanticsChoice . RelativeToNode ) , onDelete : relativeToNodeBefore . onDelete } ; const relativeToNodeStart = { ... boundToNodeStart , name : 'RelativeToNode Start(left trait)' , insertionPlace : PlaceAnchor . atStartOf ( leftTraitLocation , PlaceAnchorSemanticsChoice . RelativeToNode ) } ; const relativeToNodeEnd = { ... boundToNodeEnd , name : 'RelativeToNode End(left trait)' , insertionPlace : PlaceAnchor . atEndOf ( leftTraitLocation , PlaceAnchorSemanticsChoice . RelativeToNode ) } ; /**
 * A representative set of possible anchors with a variety of semantics and in a variety of contexts.
 * Includes the expected outcome of inserting the `inserted` node at the given anchor for various test scenarios.
 */ const anchorCases = [ boundToNodeBefore , boundToNodeAfter , boundToNodeStart , boundToNodeEnd , relativeToNodeBefore , relativeToNodeAfter , relativeToNodeStart , relativeToNodeEnd , ] ; /**
 * A set of test scenario where `inserted` is inserted at a given anchor.
 */ const insertScenarios = [ { title : 'when there are no concurrent edits' , outcomeKey : 'onNoConcurrency' } , { title : 'when target sibling is moved to a different trait' , outcomeKey : 'onMove' , concurrent : AnchoredMove . create ( StableRange . only ( left ) , StablePlace . before ( right ) ) } , { title : 'when target sibling is deleted then re-inserted in a different trait' , outcomeKey : 'onTeleport' , concurrent : [ AnchoredDelete . create ( StableRange . only ( left ) ) , ... AnchoredInsert . create ( [ left ] , StablePlace . before ( right ) ) , ] } , { title : 'when target sibling is deleted with (before, before) range' , outcomeKey : 'onDelete' , concurrent : AnchoredDelete . create ( { start : StablePlace . before ( left ) , end : StablePlace . atEndOf ( leftTraitLocation ) } ) } , { title : 'when target sibling is deleted with (before, after) range' , outcomeKey : 'onDelete' , concurrent : AnchoredDelete . create ( { start : StablePlace . before ( left ) , end : StablePlace . after ( left ) } ) } , { title : 'when target sibling is deleted with (after, before) range' , outcomeKey : 'onDelete' , concurrent : AnchoredDelete . create ( { start : StablePlace . atStartOf ( leftTraitLocation ) , end : StablePlace . atEndOf ( leftTraitLocation ) } ) } , { title : 'when target sibling is deleted with (after, after) range' , outcomeKey : 'onDelete' , concurrent : AnchoredDelete . create ( { start : StablePlace . atStartOf ( leftTraitLocation ) , end : StablePlace . after ( left ) } ) } , { title : 'when target sibling is deleted then un-deleted' , outcomeKey : 'onUndoDelete' , concurrent : ( tree ) => { const deletionEditId = tree . editor . delete ( StableRange . only ( left ) ) ; const deletionEditIndex = tree . edits . getIndexOfId ( deletionEditId ) ; const deletionEdit = tree . edits . getEditInSessionAtIndex ( deletionEditIndex ) ; return revert ( deletionEdit . changes , tree . logViewer . getSnapshotInSession ( deletionEditIndex ) ) ; } } , { title : 'when target sibling is deleted then un-deleted and re-deleted' , outcomeKey : 'onUndoRedoDelete' , concurrent : ( tree ) => { const deletionEditId = tree . editor . delete ( StableRange . only ( left ) ) ; const deletionEditIndex = tree . edits . getIndexOfId ( deletionEditId ) ; const deletionEdit = tree . edits . getEditInSessionAtIndex ( deletionEditIndex ) ; const undoEditId = tree . editor . revert ( deletionEdit , tree . logViewer . getSnapshotInSession ( deletionEditIndex ) ) ; const undoEditIndex = tree . edits . getIndexOfId ( undoEditId ) ; const undoEdit = tree . edits . getEditInSessionAtIndex ( undoEditIndex ) ; return revert ( undoEdit . changes , tree . logViewer . getSnapshotInSession ( undoEditIndex ) ) ; } } , { title : 'when target sibling parent is deleted' , outcomeKey : 'onDeleteParent' , concurrent : AnchoredDelete . create ( StableRange . only ( parent ) ) } , { title : 'when target sibling is moved in an edit that is dropped' , outcomeKey : 'onDroppedEdit' , concurrent : [ ... AnchoredMove . create ( StableRange . only ( left ) , StablePlace . before ( right ) ) , AnchoredChange . constraint ( StableRange . only ( left ) , ConstraintEffect . InvalidAndDiscard , undefined , 0 ) , ] } , { title : 'when target sibling is deleted in an edit that is dropped' , outcomeKey : 'onDroppedEdit' , concurrent : [ AnchoredDelete . create ( StableRange . only ( left ) ) , AnchoredChange . constraint ( StableRange . only ( left ) , ConstraintEffect . InvalidAndDiscard , undefined , 0 ) , ] } , { title : 'when target place is inserted at' , outcomeKey : 'onInsert' , concurrent : ( _ , anchorCase ) => AnchoredInsert . create ( [ concurrentlyInserted ] , anchorCase . insertionPlace ) } , ] ; describe ( 'SharedTreeWithAnchors' , ( ) => { describe ( 'Fulfills the SharedTree contract' , ( ) => { runSharedTreeOperationsTests < SharedTreeWithAnchors > ( 'SharedTree Operations' , setUpTestSharedTreeWithAnchors ) ; runSummaryTests < SharedTreeWithAnchors > ( 'SharedTree Summary' , setUpTestSharedTreeWithAnchors , setUpLocalServerTestSharedTreeWithAnchors ) ; } ) ; it ( 'PlaceAnchor builders default to RelativeToNode semantics' , ( ) => { const start = PlaceAnchor . atStartOf ( leftTraitLocation ) ; const end = PlaceAnchor . atEndOf ( leftTraitLocation ) ; const before = PlaceAnchor . before ( left ) ; const after = PlaceAnchor . after ( left ) ; expect ( start . semantics ) . equals ( PlaceAnchorSemanticsChoice . RelativeToNode ) ; expect ( end . semantics ) . equals ( PlaceAnchorSemanticsChoice . RelativeToNode ) ; expect ( before . semantics ) . equals ( PlaceAnchorSemanticsChoice . RelativeToNode ) ; expect ( after . semantics ) . equals ( PlaceAnchorSemanticsChoice . RelativeToNode ) ; } ) ; it ( 'RangeAnchor builders default to RelativeToNode semantics' , ( ) => { const range = RangeAnchor . only ( left ) ; expect ( range . start . semantics ) . equals ( PlaceAnchorSemanticsChoice . RelativeToNode ) ; expect ( range . end . semantics ) . equals ( PlaceAnchorSemanticsChoice . RelativeToNode ) ; } ) ; describe ( 'Basic scenarios' , ( ) => { for ( const insertScenario of insertScenarios ) { describe ( insertScenario . title , ( ) => { insertTestsWithExtraChanges ( insertScenario . outcomeKey , insertScenario . concurrent ) ; } ) ; } } ) ; describe ( `Special scenarios` , ( ) => { it ( 'when target place is invalid' , ( ) => { const { treeA , treeB , container } = setupTrees ( ) ; treeA . editor . insert ( inserted , PlaceAnchor . before ( inserted ) ) ; container . processAllMessages ( ) ; expectChangedTraits ( treeA , treeB , { } ) ; } ) ; for ( const groupInEdit of [ true , false ] ) { describe ( groupInEdit ? 'In one edit' : 'In separate edits' , ( ) => { it ( 'when target place and source range both need updating due to two deletes' , ( ) => { const { treeA , treeB , container } = setupTrees ( ) ; treeA . editor . insert ( extra , StablePlace . before ( left ) ) ; container . processAllMessages ( ) ; if ( groupInEdit ) { treeB . editor . applyChanges ( [ AnchoredDelete . create ( RangeAnchor . only ( left ) ) , AnchoredDelete . create ( RangeAnchor . all ( rightTraitLocation ) ) , ] ) ; } else { treeB . editor . delete ( RangeAnchor . only ( left ) ) ; treeB . editor . delete ( RangeAnchor . all ( rightTraitLocation ) ) ; } const beforeExtra = PlaceAnchor . before ( extra ) ; const afterExtra = PlaceAnchor . after ( extra ) ; treeA . editor . move ( RangeAnchor . from ( beforeExtra ) . to ( afterExtra ) , PlaceAnchor . before ( right ) ) ; container . processAllMessages ( ) ; expectChangedTraits ( treeA , treeB , { leftTrait : [ ] , rightTrait : [ extra ] } ) ; } ) ; } ) ; } it ( 'when target place is teleported then deleted in a single edit' , ( ) => { const { treeA , treeB , container } = setupTrees ( ) ; treeB . editor . applyChanges ( [ AnchoredDelete . create ( RangeAnchor . only ( left ) ) , ... AnchoredInsert . create ( [ left ] , PlaceAnchor . atStartOf ( rightTraitLocation ) ) , AnchoredDelete . create ( RangeAnchor . only ( left ) ) , ] ) ; treeA . editor . insert ( inserted , PlaceAnchor . before ( left ) ) ; container . processAllMessages ( ) ; expectChangedTraits ( treeA , treeB , { leftTrait : [ ] , rightTrait : [ inserted , right ] } ) ; } ) ; it ( 'when target place is teleported then deleted across edits' , ( ) => { const { treeA , treeB , container } = setupTrees ( ) ; treeB . editor . delete ( left ) ; treeB . editor . applyChanges ( [ ... AnchoredInsert . create ( [ left ] , StablePlace . atStartOf ( rightTraitLocation ) ) , AnchoredDelete . create ( StableRange . only ( left ) ) , ] ) ; treeA . editor . insert ( inserted , PlaceAnchor . before ( left ) ) ; container . processAllMessages ( ) ; expectChangedTraits ( treeA , treeB , { leftTrait : [ ] , rightTrait : [ inserted , right ] } ) ; } ) ; it ( 'when target place is deleted by the edit being rebased' , ( ) => { const { treeA , treeB , container } = setupTrees ( ) ; treeB . editor . move ( left , PlaceAnchor . before ( right ) ) ; treeA . editor . applyChanges ( [ AnchoredDelete . create ( RangeAnchor . all ( rightTraitLocation ) ) , ... AnchoredInsert . create ( [ inserted ] , PlaceAnchor . before ( left ) ) , ] ) ; container . processAllMessages ( ) ; expectChangedTraits ( treeA , treeB , { leftTrait : [ ] , rightTrait : [ inserted ] } ) ; } ) ; for ( const extraChange of [ false , true ] ) { it ( `when target place resolution requires resolution of a different place in another edit${ extraChange ? ' (with extra change)' : '' }` , ( ) => { const { treeA , treeB , container } = setupTrees ( ) ; const { tree : treeC } = setUpTestSharedTreeWithAnchors ( { containerRuntimeFactory : container , ... thirdTreeOptions } ) ; treeA . editor . insert ( extra , StablePlace . before ( right ) ) ; treeA . editor . move ( left , StablePlace . before ( extra ) ) ; container . processAllMessages ( ) ; treeB . editor . move ( left , StablePlace . after ( extra ) ) ; treeB . editor . delete ( left ) ; treeC . editor . applyChanges ( [ AnchoredDelete . create ( RangeAnchor . from ( PlaceAnchor . before ( left ) ) . to ( StablePlace . atEndOf ( rightTraitLocation ) ) ) , ... ( extraChange ? [ AnchoredChange . clearPayload ( parent . identifier ) ] : [ ] ) , ] ) ; treeA . editor . insert ( inserted , PlaceAnchor . after ( right ) ) ; container . processAllMessages ( ) ; expectChangedTraits ( treeA , treeB , { leftTrait : [ ] , rightTrait : [ extra , inserted ] } ) ; } ) ; } } ) ; } ) ; /**
 * Runs an insertion test scenario on all possible anchors.
 */ function insertTestsWithExtraChanges ( outcomeField , concurrentSteps ? ) { for ( const anchorCase of anchorCases ) { const outcome = outcomeFromCaseAndField ( anchorCase , outcomeField ) ; insertTest ( anchorCase , outcome , concurrentSteps ) ; } } /**
 * Provides the expected outcome for a particular `AnchorCase` instance and scenario (described by its `outcomeField`)
 * This helps reduce cruft in the `AnchorCase` by providing general expectations (e.g., concurrently deleting a node and undoing the
 * deletion is expected, unless otherwise specified, to yield the same outcome as through no concurrent changes were made).
 */ function outcomeFromCaseAndField ( anchorCase , outcomeField ) { if ( anchorCase [ outcomeField ] !== undefined ) { return anchorCase [ outcomeField ] as Outcome ; } switch ( outcomeField ) { case 'onUndoDelete' : return outcomeFromCaseAndField ( anchorCase , 'onNoConcurrency' ) ; case 'onUndoRedoDelete' : return outcomeFromCaseAndField ( anchorCase , 'onDelete' ) ; case 'onTeleport' : return outcomeFromCaseAndField ( anchorCase , 'onMove' ) ; case 'onDeleteParent' : return { leftTrait : [ ] , rightTrait : [ ] } ; default : fail ( 'The expected outcome for this case has not been specified' ) ; } } /**
 * Runs the insertion test characterized by the anchor as which to insert, the expected outcome, and the concurrent changes to apply.
 */ function insertTest ( anchorCase , expected , concurrentSteps ? ) { it ( anchorCase . name , ( ) => { const { tree : treeA , containerRuntimeFactory } = setUpTestSharedTreeWithAnchors ( treeOptions ) ; const { tree : treeB } = setUpTestSharedTreeWithAnchors ( { containerRuntimeFactory , ... secondTreeOptions } ) ; containerRuntimeFactory . processAllMessages ( ) ; const concurrentChanges = concurrentSteps === undefined ? [ ] : typeof concurrentSteps === 'function' ? concurrentSteps ( treeB , anchorCase ) : Array . isArray ( concurrentSteps ) ? concurrentSteps : [ concurrentSteps as AnchoredChange ] ; if ( concurrentSteps ) { treeB . editor . applyChanges ( concurrentChanges ) ; } treeA . editor . insert ( inserted , anchorCase . insertionPlace ) ; containerRuntimeFactory . processAllMessages ( ) ; expectChangedTraits ( treeA , treeB , expected ) ; } ) ; } function expectChangedTraits ( treeA , treeB , expected ) { const leftIds = ( expected . leftTrait ?? [ left ] ) . map ( ( node ) => node . identifier ) ; const rightIds = ( expected . rightTrait ?? [ right ] ) . map ( ( node ) => node . identifier ) ; expect ( tryGetTrait ( treeA , leftTraitLocation ) ) . deep . equal ( leftIds ) ; expect ( tryGetTrait ( treeB , leftTraitLocation ) ) . deep . equal ( leftIds ) ; expect ( tryGetTrait ( treeA , rightTraitLocation ) ) . deep . equal ( rightIds ) ; expect ( tryGetTrait ( treeB , rightTraitLocation ) ) . deep . equal ( rightIds ) ; } function tryGetTrait ( tree , location ) { return tree . currentView . hasNode ( location . parent ) ? tree . currentView . getTrait ( location ) : [ ] ; } function setupTrees ( ) { const { tree : treeA , containerRuntimeFactory : container } = setUpTestSharedTreeWithAnchors ( treeOptions ) ; const { tree : treeB } = setUpTestSharedTreeWithAnchors ( { containerRuntimeFactory : container , ... secondTreeOptions } ) ; container . processAllMessages ( ) ; return { treeA , treeB , container } ; }