/**
 * Copyright (c) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License.
 */ import * as React from 'react' ; import * as OF from 'office-ui-fabric-react' ; import * as CLM from '@conversationlearner/models' ; import * as Util from '../../../Utils/util' ; import * as BB from 'botbuilder' ; import * as OBIUtils from '../../../Utils/obiUtils' ; import * as Test from '../../../types/TestObjects' ; import actions from '../../../actions' ; import TranscriptRatings from '../../../components/modals/TranscriptRatings' ; import TranscriptComparisions from '../../../components/modals/TranscriptComparisons' ; import TranscriptList from '../../../components/modals/TranscriptList' ; import FormattedMessageId from '../../../components/FormattedMessageId' ; import CompareDialogsModal from '../../../components/modals/CompareDialogsModal' ; import RateDialogsModal from '../../../components/modals/RateDialogsModal' ; import ProgressModal from '../../../components/modals/ProgressModal' ; import TranscriptTestPicker from '../../../components/modals/TranscriptTestPicker' ; import ConfirmCancelModal from '../../../components/modals/ConfirmCancelModal' ; import { autobind } from 'core-decorators' ; import { connect } from 'react-redux' ; import { saveAs } from 'file-saver' ; import { State , ErrorType } from '../../../types' ; import { bindActionCreators } from 'redux' ; import { FM } from '../../../react-intl-messages' ; import { injectIntl , InjectedIntlProps } from 'react-intl' ; import './Testing.css' ; const SAVE_SUFFIX = ".cltr" ; const NUM_PARALLEL_TESTS = 30 ; interface ComponentState { testItems : Test . TestItem [ ] ; testSlots : string [ ] ; doneCount : number ; completedTestIds : string [ ] ; warnings : string [ ] ; startTime : number ; remainingTime : number ; testSet : Test . TestSet | undefined ; viewConversationIds : string [ ] | undefined ; viewConversationPivot : string | undefined ; isRateDialogsOpen : boolean ; isTestPickerOpen : boolean ; isConfirmClearModalOpen : boolean ; pivotSelection : string | undefined ; isSaveInputOpen : boolean ; } class Testing extends React . Component < Props , ComponentState > { private loadSetFileInput : any ; constructor ( props ) { super ( props ) ; this . state = { testItems : [ ] , testSlots : [ ] , doneCount : - 1 , completedTestIds : [ ] , warnings : [ ] , startTime : 0 , remainingTime : 0 , testSet : undefined , viewConversationIds : undefined , viewConversationPivot : undefined , isRateDialogsOpen : false , isTestPickerOpen : false , isConfirmClearModalOpen : false , pivotSelection : undefined , isSaveInputOpen : false } ; } async onTranscriptsChanged ( ) { if ( ! this . state . testSet || this . state . testSet . sourceNames . length === 0 ) { return ; } const testSet = Test . TestSet . Create ( this . state . testSet ) ; testSet . compareAll ( ) ; testSet . initRating ( ) ; this . setState ( { testSet : testSet } ) ; } newTestSet ( ) { const lgItems = this . props . actions . filter ( a => a . clientData ?. lgName ) . map ( a => { return { lgName : a . clientData ?. lgName ?? "" , actionId : a . actionId , text : "" , suggestions : [ ] } ; } ) ; return Test . TestSet . Create ( { appId : this . props . app . appId , lgItems } ) ; } @ autobind async onLoadTranscriptFiles ( transcriptFiles ) { if ( transcriptFiles . length > 0 ) { try { const testSet = this . state . testSet ? Test . TestSet . Create ( this . state . testSet ) : this . newTestSet ( ) ; this . props . spinnerAdd ( ) ; await testSet . addTranscriptFiles ( transcriptFiles ) ; await Util . setStateAsync ( this , { testSet } ) ; await this . onTranscriptsChanged ( ) ; } catch ( e ) { const error = e as Error ; this . props . setErrorDisplay ( ErrorType . Error , `invalid .transcript file` , error . message ) ; } finally { this . props . spinnerRemove ( ) ; } } } @ autobind async onLoadLGFiles ( lgFiles ) { if ( lgFiles . length > 0 ) { try { const testSet = this . state . testSet ? Test . TestSet . Create ( this . state . testSet ) : this . newTestSet ( ) ; await testSet . addLGFiles ( lgFiles ) ; await Util . setStateAsync ( this , { testSet } ) ; await this . onTranscriptsChanged ( ) ; } catch ( e ) { const error = e as Error ; this . props . setErrorDisplay ( ErrorType . Error , `invalid .lg file` , error . message ) ; } } } @ autobind onChangeName ( event , text ) { const testSet = Test . TestSet . Create ( this . state . testSet ) ; testSet . fileName = text ; this . setState ( { testSet : testSet } ) ; } @ autobind onCancelTest ( ) { this . setState ( { doneCount : - 1 } ) ; } @ autobind onCompare ( ) { if ( ! this . state . testSet || this . state . testSet . sourceNames . length === 0 ) { return ; } const testSet = Test . TestSet . Create ( this . state . testSet ) ; testSet . compareAll ( ) ; this . setState ( { testSet : testSet } ) ; } async onValidateTranscript ( testItem , testId ) { try { if ( ! this . state . testSet ) { throw new Error ( "Missing Test Set" ) ; } if ( ! testItem . transcript ) { throw new Error ( "Missing transcript" ) ; } const conversationId = testItem . conversationId ; const transcriptValidationTurns = [ ] ; let transcriptValidationTurn = { inputText : "" , apiResults : [ ] } ; let invalidTranscript = false ; let apiResults = [ ] ; for ( let activity of testItem . transcript ) { if ( ! activity . type || activity . type === "message" ) { if ( activity . text === "END_SESSION" ) { break ; } if ( activity . from . role === "user" ) { if ( transcriptValidationTurn . inputText !== "" ) { transcriptValidationTurns . push ( transcriptValidationTurn ) ; } transcriptValidationTurn = { inputText : activity . text , apiResults : [ ] } ; } else if ( activity . from . role === "bot" ) { if ( transcriptValidationTurn ) { if ( activity . channelData ?. type === "ActionCall" ) { const actionCall = activity . channelData as OBIUtils . TranscriptActionCall ; apiResults = await OBIUtils . importActionOutput ( actionCall . actionOutput , this . props . entities , this . props . app . appId ) ; transcriptValidationTurn . apiResults . push ( apiResults ) ; } else { transcriptValidationTurn . apiResults . push ( [ ] ) ; } } else { invalidTranscript = true ; break ; } } } } if ( transcriptValidationTurn ) { transcriptValidationTurns . push ( transcriptValidationTurn ) ; } const sourceName = `${ this . props . app . appName } (${ testItem . sourceName })` ; let validationResult ; if ( invalidTranscript ) { validationResult = { sourceName , conversationId , logDialogId : null , invalidTranscript : true } ; } else { const logDialogId = await ( ( this . props . fetchTranscriptValidationThunkAsync ( this . props . app . appId , this . props . editingPackageId , testId , transcriptValidationTurns ) as any ) as Promise < string | null > ) ; let resultTranscript ; if ( logDialogId && this . state . doneCount !== - 1 ) { resultTranscript = await OBIUtils . getLogDialogActivities ( this . props . app . appId , logDialogId , this . props . user , this . props . actions , this . props . entities , conversationId , sourceName , this . props . fetchLogDialogThunkAsync as any , this . props . fetchActivitiesThunkAsync as any ) as BB . Activity [ ] ; } else if ( ! logDialogId ) { throw new Error ( `Transcript is not a valid and can't be replayed` ) ; } const transcript = Util . deepCopy ( resultTranscript ) ?? [ ] ; OBIUtils . toLG ( transcript , this . state . testSet . lgItems , this . props . entities , this . props . actions ) ; validationResult = { sourceName , conversationId , logDialogId , transcript } ; } if ( this . state . testSet && this . state . doneCount !== - 1 ) { this . setState ( prevState => { const testSet = Test . TestSet . Create ( prevState . testSet ) ; testSet . addTestItem ( validationResult ) ; return { testSet : testSet , testSlots : [ ... prevState . testSlots , testId ] , doneCount : prevState . doneCount + 1 } ; } ) ; } } catch ( e ) { const error = e as Error ; const message = `${ testItem . conversationId }: ${ error . message }` ; this . setState ( prevState => { return { testSlots : [ ... prevState . testSlots , testId ] , doneCount : prevState . doneCount + 1 , warnings : [ ... prevState . warnings , message ] } ; } ) ; } } @ autobind async onTest ( ) { if ( ! this . state . testSet || this . state . testSet . sourceNames . length === 0 ) { return ; } if ( this . state . testSet . sourceNames . length === 1 ) { await this . startTest ( this . state . testSet . sourceNames [ 0 ] ) ; } else { this . setState ( { isTestPickerOpen : true } ) ; } } async startTest ( sourceName ) { if ( this . state . testSet ) { const testItems = this . state . testSet . getTestItems ( sourceName ) ; await Util . setStateAsync ( this , { testItems , completedTestIds : [ ] , warnings : [ ] , doneCount : 0 , startTime : new Date ( ) . getTime ( ) , remainingTime : 0 } ) ; const testSlots = [ ] ; for ( let i = 0 ; i < NUM_PARALLEL_TESTS ; i = i + 1 ) { testSlots . push ( `ValidationTest ${ i }` ) ; } await Util . setStateAsync ( this , { testSlots } ) ; setTimeout ( this . testNextTranscript , 500 ) ; } } @ autobind async testNextTranscript ( ) { if ( this . state . doneCount === - 1 || this . state . doneCount === this . state . testItems . length ) { this . setState ( { doneCount : - 1 , testItems : [ ] } ) ; await this . onTranscriptsChanged ( ) ; await this . onSaveSet ( ) ; } else { setTimeout ( this . testNextTranscript , 1000 ) ; if ( this . state . doneCount > 1 ) { const curTime = new Date ( ) . getTime ( ) ; const ellapsedTime = curTime - this . state . startTime ; const timePerItem = ellapsedTime / this . state . doneCount ; const remainingCount = this . state . testItems . length - this . state . doneCount ; const remainingTime = timePerItem * remainingCount ; this . setState ( { remainingTime } ) ; } const untestedItems = this . state . testItems . filter ( ti => ! this . state . completedTestIds . includes ( ti . conversationId ) ) ; if ( untestedItems . length !== 0 && this . state . testSlots . length > 0 ) { const testSlot = this . state . testSlots [ 0 ] ; const testItem = untestedItems [ 0 ] ; this . setState ( prevState => { return { completedTestIds : [ ... prevState . completedTestIds , testItem . conversationId ] , testSlots : prevState . testSlots . filter ( ts => ts !== testSlot ) } ; } ) ; await this . onValidateTranscript ( testItem , testSlot ) ; } } } @ autobind onView ( compareType , comparePivot ? , compareSource ? ) { if ( this . state . testSet ) { const viewConversationIds = compareSource && comparePivot ? this . state . testSet . getComparisonConversationIds ( compareSource , comparePivot , compareType ) : this . state . testSet . getAllConversationIds ( ) ; this . onViewConversationIds ( viewConversationIds , comparePivot ) ; } } @ autobind onViewConversationIds ( viewConversationIds , viewConversationPivot ? ) { if ( viewConversationIds . length > 0 ) { this . setState ( { viewConversationIds , viewConversationPivot } ) ; } } @ autobind onCloseView ( ) { this . setState ( { viewConversationIds : undefined , viewConversationPivot : undefined } ) ; } @ autobind onOpenRate ( ) { const testSet = Test . TestSet . Create ( this . state . testSet ) ; testSet . initRating ( ) ; this . setState ( { testSet : testSet , isRateDialogsOpen : true } ) ; } @ autobind async onRate ( ratingPair ) { const testSet = Test . TestSet . Create ( this . state . testSet ) ; testSet . addRatingResult ( ratingPair ) ; await Util . setStateAsync ( this , { testSet } ) ; } @ autobind async onCloseRate ( ) { this . setState ( { isRateDialogsOpen : false } ) ; await this . calcRankings ( ) ; await this . onSaveSet ( ) ; } async calcRankings ( ) { if ( this . state . testSet ) { const testSet = Test . TestSet . Create ( this . state . testSet ) ; testSet . calcRankings ( ) ; await Util . setStateAsync ( this , { testSet } ) ; } } @ autobind onPickTest ( ) { this . setState ( { isTestPickerOpen : true } ) ; } @ autobind onPickTestAbandon ( ) { this . setState ( { isTestPickerOpen : false } ) ; } @ autobind async onPickTestSubmit ( sourceName ) { this . setState ( { isTestPickerOpen : false } ) ; await this . startTest ( sourceName ) ; } @ autobind onClear ( ) { this . setState ( { isConfirmClearModalOpen : true } ) ; } @ autobind onConfirmClear ( ) { const testSet = this . newTestSet ( ) ; let fileInput = ( this . loadSetFileInput as HTMLInputElement ) ; fileInput . value = "" ; this . setState ( { testSet : testSet , isConfirmClearModalOpen : false } ) ; } @ autobind onCancelClear ( ) { this . setState ( { isConfirmClearModalOpen : false , } ) ; } @ autobind async onSaveSet ( ) { if ( ! this . state . testSet ) { return ; } if ( ! this . state . testSet . fileName ) { const testSet = Test . TestSet . Create ( this . state . testSet ) ; testSet . fileName = this . props . app . appName . replace ( /[^a-zA-Z0-9-_\.]/g , '' ) ; await Util . setStateAsync ( this , { testSet } ) ; } if ( this . state . testSet . fileName && this . onGetNameErrorMessage ( this . state . testSet . fileName ) !== '' ) { return ; } const blob = this . state . testSet . serialize ( ) ; saveAs ( blob , `${ this . state . testSet . fileName }${ SAVE_SUFFIX }` ) ; } @ autobind async onLoadSet ( files ) { const fileText = await Util . readFileAsync ( files [ 0 ] ) ; try { if ( typeof fileText !== 'string' ) { throw new Error ( "String Expected" ) ; } const testSet = Test . TestSet . Deserialize ( fileText ) ; if ( testSet . ratingPairs . length === 0 ) { testSet . compareAll ( ) ; testSet . initRating ( ) ; } await Util . setStateAsync ( this , { testSet } ) ; } catch ( e ) { const error = e as Error ; this . props . setErrorDisplay ( ErrorType . Error , error . message , "Invalid file contents" ) ; } } @ autobind nameErrorCheck ( value ) { const MAX_NAME_LENGTH = 30 ; if ( value . length === 0 ) { return '' ; } if ( value . length > MAX_NAME_LENGTH ) { return Util . formatMessageId ( this . props . intl , FM . FIELDERROR_MAX_30 ) ; } if ( ! /^[a-zA-Z0-9- ()]+$/ . test ( value ) ) { return Util . formatMessageId ( this . props . intl , FM . FIELDERROR_ALPHANUMERIC ) ; } return '' ; } @ autobind async onCloseTestWarning ( ) { this . setState ( { warnings : [ ] } ) ; } displayCount ( ) { return Math . round ( ( this . state . doneCount + this . state . completedTestIds . length ) * 0.5 ) ; } render ( ) { const saveDisabled = ! this . state . testSet || this . state . testSet . items . length === 0 || ( this . state . testSet . fileName !== undefined && this . onGetNameErrorMessage ( this . state . testSet . fileName ) !== '' ) ; return ( < div className = "cl-page" > < span className = { `cl-dialog-title cl-dialog-title--import ${ OF . FontClassNames . xxLarge }` } > < OF . Icon iconName = "TestPlan" / > < FormattedMessageId id = { FM . TESTING_TITLE } /> < /span> < span className = { OF . FontClassNames . mediumPlus } > < FormattedMessageId id = { FM . TESTING_SUBTITLE } /> < /span> < OF . TextField className = { `${ OF . FontClassNames . mediumPlus } ${ ! this . state . testSet || this . state . testSet . items . length === 0 ? ' cl-test-disabled' : '' }` } onChange = { this . onChangeName } label = { Util . formatMessageId ( this . props . intl , FM . TESTING_NAME_LABEL ) } onGetErrorMessage = { value => this . onGetNameErrorMessage ( value ) } value = { this . state . testSet ? this . state . testSet . fileName : "" } / > < div className = "cl-modal-buttons" > < input hidden = { true } type = "file" style = { { display : 'none' } } onChange = { ( event ) => this . onLoadSet ( event . target . files ) } ref = { ele => ( this . loadSetFileInput = ele ) } multiple = { false } / > < div className = "cl-modal-buttons_secondary" / > < div className = "cl-modal-buttons_primary" > < OF . DefaultButton disabled = { saveDisabled } onClick = { this . onSaveSet } ariaDescription = { Util . formatMessageId ( this . props . intl , FM . TESTING_BUTTON_SAVE_RESULTS ) } text = { Util . formatMessageId ( this . props . intl , FM . TESTING_BUTTON_SAVE_RESULTS ) } iconProps = { { iconName : 'CloudDownload' } } /> < OF . DefaultButton ariaDescription = { Util . formatMessageId ( this . props . intl , FM . TESTING_BUTTON_LOAD_RESULTS ) } text = { Util . formatMessageId ( this . props . intl , FM . TESTING_BUTTON_LOAD_RESULTS ) } iconProps = { { iconName : 'CloudUpload' } } onClick = { ( ) => this . loadSetFileInput . click ( ) } /> < OF . DefaultButton className = "cl-button-delete" disabled = { ! this . state . testSet || this . state . testSet . sourceName . length === 0 } ariaDescription = { Util . formatMessageId ( this . props . intl , FM . TESTING_BUTTON_CLEAR_RESULTS ) } text = { Util . formatMessageId ( this . props . intl , FM . TESTING_BUTTON_CLEAR_RESULTS ) } iconProps = { { iconName : 'RemoveFilter' } } onClick = { this . onClear } / > < /div> < /div> < div className = "cl-testing-body" > < OF . Pivot linkSize = { OF . PivotLinkSize . large } > < OF . PivotItem linkText = { Util . formatMessageId ( this . props . intl , FM . TESTING_PIVOT_DATA ) } > < TranscriptList testSet = { this . state . testSet } onView = { this . onView } onLoadTranscriptFiles = { this . onLoadTranscriptFiles } onLoadLGFiles = { this . onLoadLGFiles } onTest = { this . onTest } / > < /OF.PivotItem> < OF . PivotItem linkText = { Util . formatMessageId ( this . props . intl , FM . TESTING_PIVOT_COMPARISON ) } > < TranscriptComparisions testSet = { this . state . testSet } onCompare = { this . onCompare } onView = { this . onView } / > < /OF.PivotItem> < OF . PivotItem linkText = { Util . formatMessageId ( this . props . intl , FM . TESTING_PIVOT_RATING ) } > < TranscriptRatings testSet = { this . state . testSet } onRate = { this . onOpenRate } onView = { this . onViewConversationIds } / > < /OF.PivotItem> < /OF.Pivot> < /div> < ConfirmCancelModal open = { this . state . isConfirmClearModalOpen } onCancel = { this . onCancelClear } onConfirm = { this . onConfirmClear } title = { Util . formatMessageId ( this . props . intl , FM . TESTING_CONFIRM_CLEAR_TITLE ) } / > < ProgressModal open = { this . state . testItems . length > 0 } title = "Testing" index = { this . displayCount ( ) } total = { this . state . testItems . length } warningCount = { this . state . warnings . length } onClose = { this . onCancelTest } remainingTime = { this . state . remainingTime } / > { this . state . viewConversationIds && this . state . testSet && < CompareDialogsModal app = { this . props . app } testSet = { this . state . testSet } conversationIds = { this . state . viewConversationIds } conversationPivot = { this . state . viewConversationPivot } onClose = { this . onCloseView } / > } { this . state . isRateDialogsOpen && this . state . testSet && < RateDialogsModal app = { this . props . app } testSet = { this . state . testSet } onRate = { this . onRate } onClose = { this . onCloseRate } / > } { this . state . isTestPickerOpen && this . state . testSet && < TranscriptTestPicker sourceNames = { this . state . testSet . sourceNames } onAbandon = { this . onPickTestAbandon } onSubmit = { this . onPickTestSubmit } / > } { this . state . doneCount === - 1 && this . state . warnings . length > 0 && < ConfirmCancelModal open = { true } onOk = { ( ) => this . onCloseTestWarning ( ) } title = { Util . formatMessageId ( this . props . intl , FM . TESTING_WARNING ) } style = 'ms-Dialog-main--wide' message = { ( ) => < OF . List className = "cl-warning-list" items = { this . state . warnings } onRenderCell = { ( item , index ) => { return item ; } } />}/ > } < /div>); } private onGetNameErrorMessage ( value ) { if ( this . state ?. testSet ?. items . length === 0 ) { return '' ; } return this . nameErrorCheck ( value ) ; } } const mapDispatchToProps = ( dispatch ) => { return bindActionCreators ( { fetchActivitiesThunkAsync : actions . train . fetchActivitiesThunkAsync , fetchLogDialogThunkAsync : actions . log . fetchLogDialogThunkAsync , fetchTranscriptValidationThunkAsync : actions . app . fetchTranscriptValidationThunkAsync , setErrorDisplay : actions . display . setErrorDisplay , spinnerAdd : actions . display . spinnerAdd , spinnerRemove : actions . display . spinnerRemove } , dispatch ) ; } ; const mapStateToProps = ( state ) => { if ( ! state . user . user ) { throw new Error ( `You attempted to render TrainDialogs but the user was not defined. This is likely a problem with higher level component. Please open an issue.` ) ; } return { user : state . user . user , entities : state . entities , actions : state . actions } ; } ; export interface ReceivedProps { app : CLM . AppBase ; editingPackageId : string ; } type stateProps = ReturnType < typeof mapStateToProps > ; type dispatchProps = ReturnType < typeof mapDispatchToProps > ; type Props = stateProps & dispatchProps & ReceivedProps & InjectedIntlProps ; export default connect < stateProps , dispatchProps , ReceivedProps > ( mapStateToProps , mapDispatchToProps ) ( injectIntl ( Testing ) ) ;