Skip to content

Commit

Permalink
Raise Intent tests
Browse files Browse the repository at this point in the history
  • Loading branch information
robmoffat committed Feb 21, 2024
1 parent 784c294 commit b3c0691
Show file tree
Hide file tree
Showing 13 changed files with 567 additions and 156 deletions.
86 changes: 70 additions & 16 deletions packages/da/src/intents/DefaultIntentSupport.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,46 @@
import { Context, AppIntent, AppIdentifier, IntentResolution, IntentHandler, Listener } from "@finos/fdc3";
import { Context, AppIntent, AppIdentifier, IntentResolution, IntentHandler, Listener, ResolveError } from "@finos/fdc3";
import { IntentSupport } from "./IntentSupport";
import { Messaging } from "../Messaging";
import { AppDestinationIdentifier, FindIntentAgentRequest, FindIntentAgentRequestMeta, FindIntentAgentResponse, FindIntentsByContextAgentRequest, FindIntentsByContextAgentRequestMeta, FindIntentsByContextAgentResponse, RaiseIntentAgentRequest, RaiseIntentAgentRequestMeta, RaiseIntentAgentResponse, RaiseIntentResultAgentResponse } from "@finos/fdc3/dist/bridging/BridgingTypes";
import { DefaultIntentResolution } from "./DefaultIntentResolution";
import { DefaultIntentListener } from "../listeners/DefaultIntentListener";
import { IntentResolver } from "./IntentResolver";

export class DefaultIntentSupport implements IntentSupport {

readonly messaging: Messaging
readonly intentResolver: IntentResolver

constructor(messaging: Messaging) {
constructor(messaging: Messaging, intentResolver: IntentResolver) {
this.messaging = messaging
this.intentResolver = intentResolver
}

async findIntent(intent: string, context: Context, _resultType: string | undefined): Promise<AppIntent> {
const messageOut : FindIntentAgentRequest = {
async findIntent(intent: string, context: Context, resultType: string | undefined): Promise<AppIntent> {
const messageOut: FindIntentAgentRequest = {
type: "findIntentRequest",
payload: {
intent,
context
context,
resultType
},
meta: this.messaging.createMeta() as FindIntentAgentRequestMeta
}

const result = await this.messaging.exchange(messageOut, "findIntentResponse") as FindIntentAgentResponse

if (result.payload.appIntent.apps.length == 0) {
throw new Error(ResolveError.NoAppsFound)
}

return {
intent: result.payload.appIntent.intent,
apps: result.payload.appIntent.apps
}
}

async findIntentsByContext(context: Context): Promise<AppIntent[]> {
const messageOut : FindIntentsByContextAgentRequest = {
const messageOut: FindIntentsByContextAgentRequest = {
type: "findIntentsByContextRequest",
payload: {
context
Expand All @@ -42,11 +50,15 @@ export class DefaultIntentSupport implements IntentSupport {

const result = await this.messaging.exchange(messageOut, "findIntentsByContextResponse") as FindIntentsByContextAgentResponse

if (result.payload.appIntents.length == 0) {
throw new Error(ResolveError.NoAppsFound)
}

return result.payload.appIntents
}

async raiseIntentInner(intent: string | undefined, context: Context, app: AppIdentifier | undefined) : Promise<IntentResolution> {
const messageOut : RaiseIntentAgentRequest = {
private async raiseSpecificIntent(intent: string, context: Context, app: AppIdentifier): Promise<IntentResolution> {
const messageOut: RaiseIntentAgentRequest = {
type: "raiseIntentRequest",
payload: {
intent: intent as any, // raised #1157
Expand All @@ -57,31 +69,73 @@ export class DefaultIntentSupport implements IntentSupport {
}

const resolution = await this.messaging.exchange(messageOut, "raiseIntentResponse") as RaiseIntentAgentResponse
const details = resolution.payload.intentResolution
const details = resolution.payload.intentResolution

const resultPromise = this.messaging.waitFor<RaiseIntentResultAgentResponse>(
m => m.meta.requestUuid == messageOut.meta.requestUuid
)

return new DefaultIntentResolution(
this.messaging,
resultPromise,
resultPromise,
details.source,
details.intent,
details.version
)
}

private matchAppId(a: AppIdentifier, b: AppIdentifier) {
return (a.appId == b.appId) && ((a.instanceId == null) || a.instanceId == b.instanceId)
}

private filterApps(appIntent: AppIntent, app: AppIdentifier): AppIntent {
return {
apps: appIntent.apps.filter(a => this.matchAppId(a, app)),
intent: appIntent.intent
}
}

async raiseIntent(intent: string, context: Context, app?: AppIdentifier | undefined): Promise<IntentResolution> {
return this.raiseIntentInner(intent, context, app)
var matched = await this.findIntent(intent, context, undefined)

if (app) {
// ensure app matches
matched = this.filterApps(matched, app)
}

if (matched.apps.length == 0) {
throw new Error(ResolveError.NoAppsFound)
} else if (matched.apps.length == 1) {
return this.raiseSpecificIntent(intent, context, matched.apps[0])
} else {
// need to do the intent resolver
const chosentIntent = await this.intentResolver.resolveIntent([matched])
return this.raiseSpecificIntent(intent, context, chosentIntent.chosenApp)
}
}

raiseIntentForContext(context: Context, app?: AppIdentifier | undefined): Promise<IntentResolution> {
return this.raiseIntentInner(undefined, context, app)
async raiseIntentForContext(context: Context, app?: AppIdentifier | undefined): Promise<IntentResolution> {
var matched = await this.findIntentsByContext(context)

if (app) {
matched = matched
.map(m => this.filterApps(m, app))
.filter(m => m.apps.length == 0)
}

if (matched.length == 0) {
throw new Error(ResolveError.NoAppsFound)
} else if ((matched.length == 1) && (matched[0].apps.length == 1)) {
return this.raiseSpecificIntent(matched[0].intent.name, context, matched[0].apps[0])
} else {
// need to do the intent resolver
const chosentIntent = await this.intentResolver.resolveIntent(matched)
return this.raiseSpecificIntent(chosentIntent.intent.name, context, chosentIntent.chosenApp)
}
}

async addIntentListener(intent: string, handler: IntentHandler): Promise<Listener> {
return new DefaultIntentListener(this.messaging, intent, handler);
}

}
19 changes: 19 additions & 0 deletions packages/da/src/intents/IntentResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { AppIdentifier, AppIntent, IntentMetadata } from "@finos/fdc3";

/**
* Contains the details of a single intent and application resolved
* by the IntentResolver implementation
*/
export interface SingleAppIntent {

intent: IntentMetadata
chosenApp: AppIdentifier

}

export interface IntentResolver {

resolveIntent(appIntents: AppIntent[]) : SingleAppIntent

}

83 changes: 83 additions & 0 deletions packages/da/test/features/intents.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
Feature: Basic Intents Support

Background: Desktop Agent API

Given A Desktop Agent in "api"

And app "chipShop/c1" resolves intent "OrderFood" with result type "void"
And app "chipShop/c2" resolves intent "OrderFood" with result type "channel<fdc3.chips>"
And app "library/l1" resolves intent "BorrowBooks" with result type "channel<fdc3.book>"
And app "bank/b1" resolves intent "Buy" with context "fdc3.instrument" and result type "fdc3.order"
And app "travelAgent/t1" resolves intent "Buy" with context "fdc3.currency" and result type "fdc3.order"

And "instrumentContext" is a "fdc3.instrument" context
And "crazyContext" is a "fdc3.unsupported" context

Scenario: Find Intent can return the same intent with multiple apps

When I call "api" with "findIntent" with parameter "Buy"
Then "{result.intent}" is an object with the following contents
| name |
| Buy |
And "{result.apps}" is an array of objects with the following contents
| appId | instanceId |
| bank | b1 |
| travelAgent | t1 |

Scenario: Find Intent can return an error when an intent doesn't match

When I call "api" with "findIntent" with parameter "Bob"
Then "result" is an error with message "NoAppsFound"

Scenario: Find Intent can filter by a context type

When I call "api" with "findIntent" with parameters "Buy" and "{instrumentContext}"
Then "{result.intent}" is an object with the following contents
| name |
| Buy |
And "{result.apps}" is an array of objects with the following contents
| appId |
| bank |

Scenario: Find Intent can filter by generic result Type

When I call "api" with "findIntent" with parameters "OrderFood" and "{empty}" and "channel<fdc3.chips>"
Then "{result.intent}" is an object with the following contents
| name |
| OrderFood |
And "{result.apps}" is an array of objects with the following contents
| appId | instanceId |
| chipShop | c2 |

Scenario: Find Intents By Context

When I call "api" with "findIntentsByContext" with parameter "{instrumentContext}"
Then "{result}" is an array of objects with the following contents
| intent.name | apps[0].appId | apps.length |
| Buy | bank | 1 |

Scenario: Find Intents By Context can return an error when an intent doesn't match

When I call "api" with "findIntentsByContext" with parameter "{crazyContext}"
Then "result" is an error with message "NoAppsFound"

Scenario: Raising A Specific Intent to the server, returning a context object

When I call "api" with "raiseIntent" with parameters "Buy" and "{instrumentContext}" and "{b1}"
Then "{result}" is an object with the following contents
| source.appId | source.instanceId | intent |
| bank | b1 | Buy |
And I call "result" with "getResult"
Then "{result}" is an object with the following contents
| type | name |
| fdc3.order | Big Order 9 |

Scenario: Raising An Invalid Intent to the server

When I call "api" with "raiseIntent" with parameters "Buy" and "{instrumentContext}" and "{c1}"
Then "result" is an error with message "NoAppsFound"

# Scenario: Invoking the intent resolver when it's not clear which intent is required

# When I call "api" with "raiseIntent" with parameters "OrderFood" and "{instrumentContext}"

4 changes: 4 additions & 0 deletions packages/da/test/step-definitions/channels.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const contextMap : Record<string, any> = {
"COUNTRY_ISOALPHA2": "SE",
"COUNTRY_ISOALPHA3": "SWE",
}
},
"fdc3.unsupported" : {
"type": "fdc3.unsupported",
"bogus": true
}
}

Expand Down
61 changes: 52 additions & 9 deletions packages/da/test/step-definitions/generic.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,31 @@ import { expect } from 'expect';
import { doesRowMatch, handleResolve, matchData } from '../support/matching';
import { CustomWorld } from '../world/index';
import { BasicDesktopAgent, DefaultAppSupport, DefaultChannelSupport, DefaultIntentSupport } from '../../src';
import { IntentResolver, SingleAppIntent } from '../intents/IntentResolver';
import { AppIntent } from '@finos/fdc3';

/**
* This super-simple intent resolver just resolves to the first
* intent / app in the list.
*/
class SimpleIntentResolver implements IntentResolver {

constructor(cw: CustomWorld) {
this.cw = cw;
}

cw: CustomWorld

resolveIntent(appIntents: AppIntent[]): SingleAppIntent {
const out = {
intent: appIntents[0].intent,
chosenApp :appIntents[0].apps[0]
}

this.cw.props['intent-resolution'] = out
return out
}
}

Given('A Desktop Agent in {string}', function (this: CustomWorld, field: string) {

Expand All @@ -14,7 +39,7 @@ Given('A Desktop Agent in {string}', function (this: CustomWorld, field: string)

this.props[field] = new BasicDesktopAgent (
new DefaultChannelSupport(this.messaging, createDefaultChannels(this.messaging), null),
new DefaultIntentSupport(this.messaging),
new DefaultIntentSupport(this.messaging, new SimpleIntentResolver(this)),
new DefaultAppSupport(this.messaging, {
appId: "Test App Id",
desktopAgent: "Test DA",
Expand All @@ -28,9 +53,13 @@ Given('A Desktop Agent in {string}', function (this: CustomWorld, field: string)
})

When('I call {string} with {string}', async function (this: CustomWorld, field: string, fnName: string) {
const fn = this.props[field][fnName];
const result = await fn.call(this.props[field])
this.props['result'] = result;
try {
const fn = this.props[field][fnName];
const result = await fn.call(this.props[field])
this.props['result'] = result;
} catch (error) {
this.props['result'] = error
}
})

When('I call {string} with {string} with parameter {string}', async function (this: CustomWorld, field: string, fnName: string, param: string) {
Expand All @@ -44,17 +73,31 @@ When('I call {string} with {string} with parameter {string}', async function (th
})

When('I call {string} with {string} with parameters {string} and {string}', async function (this: CustomWorld, field: string, fnName: string, param1: string, param2: string) {
const fn = this.props[field][fnName];
const result = await fn.call(this.props[field], handleResolve(param1, this), handleResolve(param2, this))
this.props['result'] = result;
try {
const fn = this.props[field][fnName];
const result = await fn.call(this.props[field], handleResolve(param1, this), handleResolve(param2, this))
this.props['result'] = result;
} catch (error) {
this.props['result'] = error
}
});

When('I call {string} with {string} with parameters {string} and {string} and {string}', async function (this: CustomWorld, field: string, fnName: string, param1: string, param2: string, param3: string) {
try {
const fn = this.props[field][fnName];
const result = await fn.call(this.props[field], handleResolve(param1, this), handleResolve(param2, this), handleResolve(param3, this))
this.props['result'] = result;
} catch (error) {
this.props['result'] = error
}
});

When('I refer to {string} as {string}', async function (this: CustomWorld, from: string, to: string) {
this.props[to] = this.props[from];
})

Then('{string} is an array of objects with the following contents', function (this: CustomWorld, field: string, dt: DataTable) {
matchData(this, this.props[field], dt)
matchData(this, handleResolve(field, this), dt)
});

Then('{string} is an array of strings with the following values', function (this: CustomWorld, field: string, dt: DataTable) {
Expand All @@ -64,7 +107,7 @@ Then('{string} is an array of strings with the following values', function (this

Then('{string} is an object with the following contents', function (this: CustomWorld, field: string, params: DataTable) {
const table = params.hashes()
expect(doesRowMatch(this, table[0], this.props[field])).toBeTruthy();
expect(doesRowMatch(this, table[0], handleResolve(field, this))).toBeTruthy();
});

Then('{string} is null', function (this: CustomWorld, field: string) {
Expand Down
Loading

0 comments on commit b3c0691

Please sign in to comment.