/**
 * matching tests with assertion by its `context`, i.e. describe/test block structure
 * as well as sequence (by relative line position) in its context.
 *
 * The assumption is both source parser and jest have generated correct output,
 * while the names of the test might not always match (such as template-literal, jest.each use cases),
 * nor the line numbers (due to source map difference and other transpile complication),
 * the relative position should be the same, i.e. the first assertion in describe-block X
 * should always match the first test block under the same describe block and so on.
 */ import { ItBlock , TestAssertionStatus , ParsedNode , DescribeBlock , Location , NamedBlock , } from 'jest-editor-support' ; import { TestReconciliationState } from './TestReconciliationState' ; import { TestResult } from './TestResult' ; import { DataNode , ContainerNode , ContextType , ChildNodeType , ROOT_NODE_NAME , OptionalAttributes , MatchEvent , NodeType , MatchOptions , } from './match-node' ; export const buildAssertionContainer = ( assertions ) : ContainerNode < TestAssertionStatus > => { const root = new ContainerNode < TestAssertionStatus > ( ROOT_NODE_NAME ) ; if ( assertions . length > 0 ) { assertions . forEach ( ( a ) => { const container = root . findContainer ( a . ancestorTitles , ( name ) => new ContainerNode ( name , { isGroup : 'maybe' } ) ) ; const zeroBasedLine = a . location ? a . location . line : a . line ? a . line - 1 : - 1 ; container ?. addDataNode ( new DataNode ( a . title , zeroBasedLine , a , { fullName : a . fullName , isGroup : 'maybe' , range : { start : { line : zeroBasedLine , column : 0 } , end : { line : zeroBasedLine , column : 0 } , } , } ) ) ; } ) ; root . sort ( true ) ; } return root ; } ; const UnknownRange = { start : { line : - 1 , column : - 1 } , end : { line : - 1 , column : - 1 } } ; export const buildSourceContainer = ( sourceRoot ) : ContainerNode < ItBlock > => { const isDescribeBlock = ( node ) : node is DescribeBlock => node . type === 'describe' ; const isItBlock = ( node ) : node is ItBlock => node . type === 'it' ; const buildNode = ( node , parent ) : void => { let container = parent ; const attrs = ( namedNode ) : OptionalAttributes => ( { isGroup : namedNode . lastProperty === 'each' ? 'yes' : 'no' , nonLiteralName : namedNode . nameType !== 'Literal' , range : { start : namedNode . start ? adjustLocation ( namedNode . start ) : UnknownRange . start , end : namedNode . end ? adjustLocation ( namedNode . end ) : UnknownRange . end , } , } ) ; if ( isDescribeBlock ( node ) ) { container = new ContainerNode ( node . name , attrs ( node ) ) ; parent . addContainerNode ( container ) ; } else if ( isItBlock ( node ) ) { parent . addDataNode ( new DataNode ( node . name , node . start . line - 1 , node , attrs ( node ) ) ) ; } node . children ?. forEach ( ( n ) => buildNode ( n , container ) ) ; } ; const root = new ContainerNode < ItBlock > ( ROOT_NODE_NAME ) ; buildNode ( sourceRoot , root ) ; root . sort ( false ) ; root . checkDuplicateName ( ) ; return root ; } ; const adjustLocation = ( l ) : Location => ( { column : l . column - 1 , line : l . line - 1 } ) ; const matchPos = ( t , a , forError = false ) : boolean => { const line = forError ? a . line : a . line ?? a . location ?. line ; return ( line != null && line >= t . start . line && line <= t . end . line ) || false ; } ; const isSourceDataNode = ( arg ) : arg is DataNode < ItBlock > => ( arg as any ) . data ; export const toMatchResult = ( source , assertionOrErr , reason ) : TestResult => { const [ test , sourceHistory , sourceName ] = isSourceDataNode ( source ) ? [ source . data , source . history ( reason ) , source . fullName ] : [ source , [ reason ] , source . name ] ; const [ assertion , assertionHistory , err ] = typeof assertionOrErr === 'string' ? [ undefined , undefined , assertionOrErr ] : [ assertionOrErr . data , assertionOrErr . history ( reason ) , undefined ] ; return { name : assertion ?. fullName ?? assertion ?. title ?? sourceName , identifier : { title : assertion ?. title || test . name , ancestorTitles : assertion ?. ancestorTitles || [ ] , } , start : adjustLocation ( test . start ) , end : adjustLocation ( test . end ) , status : assertion ?. status ?? TestReconciliationState . Unknown , shortMessage : assertion ?. shortMessage ?? err , terseMessage : assertion ?. terseMessage , lineNumberOfError : assertion ?. line && matchPos ( test , assertion , true ) ? assertion . line - 1 : test . end . line - 1 , sourceHistory , assertionHistory , } ; } ; type MessagingInfo = { type : 'report-unmatched' ; unmatchedItBlocks : DataNode < ItBlock > [ ] ; unmatchedAssertions : DataNode < TestAssertionStatus > [ ] ; tContainer : ContainerNode < ItBlock > ; aContainer : ContainerNode < TestAssertionStatus > ; } ; const createMessaging = ( fileName , _verbose ) => ( info ) : void => { const build = ( msg ) : Parameters < typeof console . log > => [ `[test resut matching] ${ info . type } : ${ msg } : "${ fileName }"\n` , info , ] ; switch ( info . type ) { case 'report-unmatched' : console . warn ( ... build ( `${ info . unmatchedItBlocks . length } unmatched test blocks` ) ) ; break ; } } ; type MatchResultType < C extends ContextType > = [ ChildNodeType < ItBlock , C > , ChildNodeType < TestAssertionStatus , C > [ ] , MatchEvent ] ; interface ContextMatchAlgorithm { match : ( tContainer : ContainerNode < ItBlock > , aContainer : ContainerNode < TestAssertionStatus > , messaging : ReturnType < typeof createMessaging > ) => TestResult [ ] ; } interface MatchMethodResult < C extends ContextType > { unmatchedT : ChildNodeType < ItBlock , C > [ ] ; results : MatchResultType < C > [ ] ; } interface FallbackMatchResult < C extends ContextType > { matched ? : TestResult [ ] ; unmatchedT ? : ChildNodeType < ItBlock , C > [ ] ; unmatchedA ? : ChildNodeType < TestAssertionStatus , C > [ ] ; } type ClassicMatchType = 'by-name' | 'by-location' ; const ContextMatch = ( ) : ContextMatchAlgorithm => { const onMatch = ( tNode , aNode , event ) : void => { tNode . addEvent ( event ) ; aNode . addEvent ( event ) ; aNode . attrs . range = tNode . attrs . range ; } ; const onMatchResult = ( tNode , aNode , event ) : TestResult => { onMatch ( tNode , aNode , event ) ; return toMatchResult ( tNode , aNode , event ) ; } ; const handleTestBlockMatch = ( result , reportUnmatch = false , matchGroup = true ) : TestResult [ ] => { const [ t , matched , reason ] = result ; if ( matched . length === 0 ) { if ( reportUnmatch ) { return [ toMatchResult ( t , `found ${ matched . length } matched assertion(s)` , reason ) ] ; } return [ ] ; } return matched . flatMap ( ( a ) => matchGroup ? a . getAll ( ) . map ( ( aa ) => onMatchResult ( t , aa , reason ) ) : onMatchResult ( t , a , reason ) ) ; } ; const handleDescribeBlockMatch = ( result , matchGroup = true ) : TestResult [ ] => { const [ t , matched , reason ] = result ; if ( matched . length === 0 ) { return [ ] ; } t . addEvent ( reason ) ; const _matchContainers = ( a ) => { onMatch ( t , a , reason ) ; return matchContainers ( t , a ) ; } ; return matched . flatMap ( ( a ) => matchGroup ? a . getAll ( ) . flatMap ( _matchContainers ) : _matchContainers ( a ) ) ; } ; const matchByNameOptions = { ignoreGroupDiff : true } ; const matchByLocationOptions = { checkIsWithin : true , ignoreNonLiteralNameDiff : true , ignoreGroupDiff : true , } ; const classicMatch = < C extends ContextType > ( type , tList , aList ) : MatchMethodResult < C > => { const reason = type === 'by-name' ? 'match-by-name' : 'match-by-location' ; const options = type === 'by-name' ? matchByNameOptions : matchByLocationOptions ; const results = [ ] ; const unmatchedT = tList . filter ( ( t ) => { if ( type === 'by-name' && t . hasEvent ( 'duplicate-name' ) ) { return true ; } const matched = aList . filter ( ( a ) => a . match ( t , options ) ) ; if ( matched . length <= 0 ) { return true ; } if ( matched . length === 1 || t . isGroupNode ( ) === 'yes' ) { results . push ( [ t , matched , reason ] ) ; return false ; } return true ; } ) ; return { unmatchedT , results } ; } ; const matchByContextOptions = { acceptLocalNameMatch : true , ignoreNonLiteralNameDiff : true , } ; const matchByContext = < C extends ContextType > ( tList , aList ) : MatchMethodResult < C > => { const results = [ ] ; if ( tList . length === aList . length ) { const hasMismatch = tList . find ( ( t , idx ) => ! aList [ idx ] . match ( t , matchByContextOptions ) ) ; if ( ! hasMismatch ) { tList . forEach ( ( t , idx ) => results . push ( [ t , [ aList [ idx ] ] , 'match-by-context' ] ) ) ; return { unmatchedT : [ ] , results } ; } } return { unmatchedT : tList , results : [ ] } ; } ; const matchChildren = < C extends ContextType > ( contextType , tContainer , aContainer , onResult ) : void => { const results = [ ] ; let tList = tContainer . getChildren ( contextType ) ; if ( tList . length <= 0 ) { return ; } const handleMatchReturn = ( aReturn ) : void => { results . push ( ... aReturn . results ) ; tList = aReturn . unmatchedT ; } ; let aList = aContainer . getChildren ( contextType ) ; const invalidLocations = aList . filter ( ( n ) => n . hasEvent ( 'invalid-location' ) ) ; if ( invalidLocations . length > 0 ) { aList = aList . filter ( ( n ) => ! n . hasEvent ( 'invalid-location' ) ) ; handleMatchReturn ( classicMatch ( 'by-name' , tList , invalidLocations ) ) ; } handleMatchReturn ( matchByContext ( tList , aList ) ) ; tList . forEach ( ( t ) => results . push ( [ t , [ ] , 'match-failed' ] ) ) ; results . forEach ( ( r ) => onResult ( r ) ) ; } ; param tContainer param aContainer returns const matchContainers = ( tContainer , aContainer ) : TestResult [ ] => { const matchResults = [ ] ; matchChildren ( 'data' , tContainer , aContainer , ( result ) => matchResults . push ( ... handleTestBlockMatch ( result ) ) ) ; matchChildren ( 'container' , tContainer , aContainer , ( result ) => matchResults . push ( ... handleDescribeBlockMatch ( result ) ) ) ; return matchResults ; } ; param tContainer param aContainer returns const matchFallback = ( tContainer , aContainer ) : FallbackMatchResult < 'data' > => { const doMatch = < C extends ContextType > ( type , onResult ) => { let tList = tContainer . unmatchedNodes ( type ) ; if ( tList . length <= 0 ) { return { } ; } let aList = aContainer . unmatchedNodes ( type ) ; const matched = ( [ 'by-name' , 'by-location' ] as ClassicMatchType [ ] ) . flatMap ( ( matchType ) => { const matchResult = classicMatch ( matchType , tList , aList ) ; tList = matchResult . unmatchedT ; return matchResult . results . flatMap ( onResult ) ; } ) ; aList = aList . filter ( ( a ) => ! a . isMatched ) ; return { matched , unmatchedT : tList , unmatchedA : aList , } ; } ; const cFallback = doMatch ( 'container' , ( r ) => { const [ t ] = r ; return t . isMatched ? [ ] : handleDescribeBlockMatch ( r , false ) ; } ) ; const dFallback = doMatch ( 'data' , ( r ) => handleTestBlockMatch ( r , false , false ) ) ; return { ... dFallback , matched : ( cFallback . matched ?? [ ] ) . concat ( dFallback . matched ?? [ ] ) , } ; } ; const toUnmatchedResults = ( nodes ) : TestResult [ ] => nodes . flatMap ( ( t ) => handleTestBlockMatch ( [ t , [ ] , 'match-failed' ] , true , false ) ) ; const match = ( tContainer , aContainer , messaging ) : TestResult [ ] => { let matched = matchContainers ( tContainer , aContainer ) ; const fallback = matchFallback ( tContainer , aContainer ) ; matched = fallback . matched ? matched . concat ( fallback . matched ) : matched ; const unmatchedResults = fallback . unmatchedT && toUnmatchedResults ( fallback . unmatchedT ) ; if ( unmatchedResults && unmatchedResults . length > 0 ) { matched = unmatchedResults ? matched . concat ( unmatchedResults ) : matched ; messaging ( { type : 'report-unmatched' , unmatchedItBlocks : fallback . unmatchedT ?? [ ] , unmatchedAssertions : fallback . unmatchedA ?? [ ] , tContainer , aContainer , } ) ; } return matched ; } ; return { match } ; } ; param fileName param tContainer param aContainer param verbose const { match } = ContextMatch ( ) ; export const matchTestAssertions = ( fileName , sourceRoot , assertions , verbose = false ) : TestResult [ ] => { const tContainer = buildSourceContainer ( sourceRoot ) ; const aContainer = Array . isArray ( assertions ) ? buildAssertionContainer ( assertions ) : assertions ; const messaging = createMessaging ( fileName , verbose ) ; return match ( tContainer , aContainer , messaging ) ; } ;