Skip to content
This repository has been archived by the owner on Feb 17, 2022. It is now read-only.

Add support for multiple attachments #8

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ concise, simple test files with strong flexibility and extensibility.
- Parallel execution of tests
- Test against multiple acceptable answers
- Wildcard matching (`<NUMBER>`, `<WORD>`, `<*>`)
- Support for testing rich attachments (`<IMAGE>`, `<CARDS>`)
- Support for testing rich attachments (`<IMAGE>`, `<CARDS>`, `<OTHER>`)
- Support for regular expressions (`<(st|nt|nd)>`)
- Show diffs on error
- Conversation branches
Expand Down Expand Up @@ -71,6 +71,28 @@ Dialogue:
- Bot: <WORD>
```

Handling multiple attachments

```YAML
Title: Conversation with rich messages
Dialogue:
- Bot: Hi
- Human: Hello
- Human: How are we doing this month?
- Bot: "Month-to-date: <IMAGE> <CARDS>"
- Human: Show me ad spend compared to last month
- Bot: <IMAGE> <IMAGE>
- Human: How is the campaign doing overall?
- Bot: According to <IMAGE> and <IMAGE>, it's going great!
```

Currently, the order and type of the attachments are checked, as is the text
that surrounds them, but not the position of the attachments within the text.

> **NOTE**: In versions `0.0.11` and earlier, the text was not checked at all
> when an attachment was present. If you were sending text along with the
> attachment, you should add it to your dialogue when upgrading.

## Special Tags

You can use the following tags in your test dialogue files:
Expand All @@ -80,7 +102,8 @@ Tag | Meaning
`<*>` | Matches anything, including whitespaces
`<WORD>` | A single word without whitespaces
`<IMAGE>` | An image attachment
`<CARDS>` | A card attachment
`<CARDS>` | A card attachment (this includes buttons)
`<OTHER>` | Any other type of attachment (video, etc.)
`<(REGEX)>` | Any regex expression, i.e. `<([0-9]{2})>`
`<$VARNAME>` | An expression from the locale/translation file

Expand Down
6 changes: 6 additions & 0 deletions dialogues/examples/multiple-attachments.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Title: Multiple attachments
Dialogue:
- Human: Hello
- Bot: Hi
- Human: How is my campaign doing?
- Bot: "Here are the stats for this week: <IMAGE> <CARDS>"
6 changes: 6 additions & 0 deletions dialogues/examples/simple-attachment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Title: Simple attachment
Dialogue:
- Human: Hello
- Bot: Hi
- Human: How is my campaign doing?
- Bot: <IMAGE>
16 changes: 7 additions & 9 deletions src/clients/botframework_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
Activity,
Message as BotMessage } from 'botframework-directlinejs';
import { Client } from './client_interface';
import { Message, MessageType } from '../spec/message';
import { Message, Attachment } from '../spec/message';
import * as program from 'commander';
import * as chalk from 'chalk';

Expand Down Expand Up @@ -78,16 +78,14 @@ export class BotFrameworkClient extends Client {
}
const message: Message = {
user,
messageTypes: [
dlMessage.text && MessageType.Text,
dlMessage.attachments && dlMessage.attachments.some(a =>
a.contentType.includes('image')) && MessageType.Image,
dlMessage.attachments && dlMessage.attachments.some(a =>
a.contentType.includes('hero')) && MessageType.Card,
].filter(m => m),
attachments: dlMessage.attachments.map((a) => {
if (a.contentType.includes('image')) return Attachment.Image;
if (a.contentType.includes('hero')) return Attachment.Cards;
return Attachment.Other;
}),
text: dlMessage.text || null,
};
if (message.messageTypes.length) {
if (message.text || message.attachments.length) {
callback(message);
return;
}
Expand Down
13 changes: 8 additions & 5 deletions src/runner.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use strict';
import { Client } from './clients/client_interface';
import { Dialogue } from './spec/dialogue';
import { Message, MessageType } from './spec/message';
import { Message, Attachment } from './spec/message';
import { Response } from './spec/response';
import { Turn, TurnType } from './spec/turn';
import { Config } from './config';
Expand Down Expand Up @@ -156,12 +156,12 @@ export class Runner {
if (response !== null) {
if (program.verbose) {
const timing = (new Date().getTime() - this.timing) || 0;
if (response.messageTypes.includes(MessageType.Text)) {
if (response.text) {
const texts = response.text.split('\n');
const truncatedText = texts.length > 5
? texts.slice(0, 5).join('\n') + '...' : response.text;
console.log(chalk.blue('\tBOT', response.user, ':',
truncatedText),
truncatedText, ...response.attachments.map(a => `<${a}>`)),
chalk.magenta(` (${timing}ms)`));
} else {
console.log(chalk.blue('\tBOT:', response.user, ':', JSON.stringify(response)),
Expand Down Expand Up @@ -192,12 +192,15 @@ export class Runner {
expected = `\t\t${matchArray[0]}`;
}

let actual = response.text ? [response.text] : [];
actual = actual.concat(response.attachments.map(a => `<${a}>`));

this.results.set(test.dialogue, {
dialogue: test.dialogue,
passed: false,
errorMessage: chalk.red(`\t${Runner.getUsername(test)}\n`) +
`\tExpected:\n${expected}` +
`\n\tGot:\n\t\t${response.text}`,
`\n\tGot:\n\t\t${actual.join(' ') || null}`,
});
this.terminateInstance(test);
return;
Expand Down Expand Up @@ -261,7 +264,7 @@ export class Runner {
}
this.client.send({
user,
messageTypes: [MessageType.Text],
attachments: [],
text: next.query,
});
}
Expand Down
17 changes: 12 additions & 5 deletions src/spec/message.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
export enum MessageType {
Text = 'Text',
Image = 'Image',
Card = 'Card',
/*
* An enumeration of the possible attachment types.
*
* `Other` is a catch-all for any attachment type that is not (yet) explicitly
* supported. The enum values are used to build the regexes for the attachment
* wildcards, e.g. <IMAGE> is derived from 'IMAGE'.
*/
export enum Attachment {
Image = 'IMAGE',
Cards = 'CARDS',
Other = 'OTHER',
}

export interface Message {
messageTypes: MessageType[];
attachments: Attachment[];
text: string;
user: string;
}
77 changes: 55 additions & 22 deletions src/spec/response.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,83 @@
import { Message, MessageType } from './message';
import { Message, Attachment } from './message';
import { Translator } from '../translator';
import * as program from 'commander';

const wildcardRegex: RegExp = /<\*>/g;
const wordRegex: RegExp = /<WORD>/g;
const numberRegex: RegExp = /<NUMBER>/g;
const imageRegex: RegExp = /<IMAGE>/g;
const cardsRegex: RegExp = /<CARDS>/g;
const regexRegex: RegExp = /<\((.+?)\)>/g;

// Use the Attachment enum values to create a regex for any attachment wildcard.
const attachmentAlternatives = Object.values(Attachment).join('|');
const attachmentsRegex = new RegExp(`<(?:${attachmentAlternatives})>`, 'g');

export class Response {
responseType: MessageType;
attachments: Attachment[];
private textMatchChecker: RegExp;
private bodyText: string;
original: string;

constructor(responseData: string) {
this.original = responseData.trim();
switch (this.original) {
case '<IMAGE>':
this.responseType = MessageType.Image;
break;
case '<CARDS>':
this.responseType = MessageType.Card;
break;
default:
this.responseType = MessageType.Text;
this.textMatchChecker = new RegExp(Response.transformTags(this.original));
}

// Get a list of the Attachments in the response data. The map removes the
// angle brackets so that the resulting elements are valid enum values.
this.attachments = (this.original.match(attachmentsRegex) || [])
.map(match => <Attachment>match.slice(1, -1));

// Get what's left of the body text once the attachment wildcards have been
// removed. Each attachment consumes the surrounding whitespace, which in
// the end we replace with a single space. This is done so that you can
// write e.g. '<IMAGE> <CARDS>' as a message spec and _not_ have the
// matcher try to match the message text with the space in between the
// wildcards in the spec.
//
// TODO: Use the array just after the map() to create an _array of match
// checkers_ so that we can validate the interleaving of attachments and
// message text, rather than ignoring where the attachments come within
// the text.
this.bodyText = this.original // 'Here you go: <IMAGE> and <CARDS>'
.split(attachmentsRegex) // ['Here you go: ', ' and ', '']
.map(s => s.trim()) // ['Here you go:', 'and', '']
.filter(s => s) // ['Here you go:', 'and']
.join(' '); // 'Here you go: and'

// Construct a regex to match the remaining body text.
this.textMatchChecker = new RegExp(Response.transformTags(this.bodyText));
}

matches(message: Message): boolean {
if (!message.messageTypes.includes(this.responseType)) {

// Check that the message body is present or absent, as expected.
if ((this.bodyText && !message.text) || (message.text && !this.bodyText)) {
return false;
} else if (this.responseType === MessageType.Text) {
return message.text.trim().match(this.textMatchChecker) !== null;
} else {
return true;
}

// If there is a message body, check that its content is correct.
if (this.bodyText && message.text.trim().match(this.textMatchChecker) === null) {
return false;
}

// Check that we have the right number of attachments.
if (this.attachments.length !== message.attachments.length) {
return false;
}

// Check that we have the right types of attachments.
for (let i = 0; i < this.attachments.length; i += 1) {
if (this.attachments[i] !== message.attachments[i]) {
return false;
}
}

// Everything appears to be in order.
return true;
}

static transformTags(text: string): string {
regexRegex.lastIndex = 0;

const taggedText = text
.replace(imageRegex, '')
.replace(cardsRegex, '')
.replace(wildcardRegex, '<([\\s\\S]*?)>')
.replace(wordRegex, '<([^ ]+?)>')
.replace(numberRegex, '<([0-9\.,-]+)>');
Expand Down
Loading