From bf42d13154a0a05c859f6db7fe494e7b86af50a7 Mon Sep 17 00:00:00 2001 From: Ryan McNally Date: Thu, 13 Jun 2024 12:52:54 +0100 Subject: [PATCH] Basis correlation fix (#843) Fixed basis view on tagged interactions --- .../test/flow/report/WriterTest.java | 6 +- .../report/detail/AbstractDetailTest.java | 9 +- .../detail/AbstractFlowSequenceTest.java | 10 +- .../report/detail/AbstractLogViewTest.java | 6 +- .../report/detail/AbstractResidueTest.java | 2 +- .../report/detail/FileFlowSequenceTest.java | 4 +- .../report/detail/ServedFlowSequenceTest.java | 4 +- report/report-ng/package.json | 3 +- .../src/app/basis-fetch.service.spec.ts | 212 ++++++++++++++++++ .../report/src/app/basis-fetch.service.ts | 57 +++-- .../src/app/detail/detail.component.spec.ts | 20 +- .../duct-index-item.component.spec.ts | 4 +- .../duct-index/duct-index.component.spec.ts | 6 + .../src/app/index/index.component.spec.ts | 7 + 14 files changed, 314 insertions(+), 36 deletions(-) create mode 100644 report/report-ng/projects/report/src/app/basis-fetch.service.spec.ts diff --git a/report/report-core/src/test/java/com/mastercard/test/flow/report/WriterTest.java b/report/report-core/src/test/java/com/mastercard/test/flow/report/WriterTest.java index 501d10c1cd..1ad530bd9c 100644 --- a/report/report-core/src/test/java/com/mastercard/test/flow/report/WriterTest.java +++ b/report/report-core/src/test/java/com/mastercard/test/flow/report/WriterTest.java @@ -247,7 +247,7 @@ void chunkLoadingPath() throws Exception { assertEquals( "[\"res/\"+]", delta.getTarget().getLines().toString(), "inserted lines count" ); - int context = 15; + int context = 14; String before = resource.substring( delta.getSource().getPosition() - context, delta.getSource().getPosition() + context ) @@ -258,9 +258,9 @@ void chunkLoadingPath() throws Exception { + delta.getTarget().getLines().get( 0 ).length() ) .replaceAll( "\\d", "#" ); - assertEquals( "f),[])),a.u=e=>(###===e?\"commo", + assertEquals( "),[])),a.u=e=>(###===e?\"comm", before, "raw resource runtime snippet" ); - assertEquals( "f),[])),a.u=e=>\"res/\"+(###===e?\"commo", + assertEquals( "),[])),a.u=e=>\"res/\"+(###===e?\"comm", after, "written runtime snippet" ); } diff --git a/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/AbstractDetailTest.java b/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/AbstractDetailTest.java index a0cca817cf..08500c7aca 100644 --- a/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/AbstractDetailTest.java +++ b/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/AbstractDetailTest.java @@ -116,9 +116,14 @@ static void setup() throws Exception { .prerequisite( problem ) .prerequisite( confirmation ) .context( GroupState.class, gs -> gs.che().guilt( "undeniable" ) ) - .update( i -> i.responder() == CHE, i -> i.response() - .set( ".+", "No, I'm worried about her dairy consumption.\n" + .update( i -> i.responder() == CHE, i -> i + .tags( Tags.add( "denial" ) ) + .response().set( ".+", "No, I'm worried about her dairy consumption.\n" + "I'm cutting you both off" ) ) + .addCall( i -> i.responder() == BEN, 1, a -> a + .to( CHE ).tags( Tags.add( "confirmation" ) ) + .request( new Text( "She's an adult, she can have cheese if she wants to!" ) ) + .response( new Text( "Feel free to shop elsewhere." ) ) ) .update( i -> i.responder() == BEN, i -> i.response() .set( ".+", "Sorry Ava, no brie today" ) ) .residue( GroupState.class, gs -> { diff --git a/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/AbstractFlowSequenceTest.java b/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/AbstractFlowSequenceTest.java index 24ee38c110..f392df4e25 100644 --- a/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/AbstractFlowSequenceTest.java +++ b/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/AbstractFlowSequenceTest.java @@ -26,17 +26,17 @@ void messages() { .onTransmission( "BEN response" ); fseq.onExpected() - .hasUrlArgs( "display=Expected", "msg=3" ) + .hasUrlArgs( "display=Expected", "msg=5" ) .hasMessage( "Sorry Ava, no brie today" ); fseq.onActual() - .hasUrlArgs( "msg=3" ) // Actual is the default, no arg required + .hasUrlArgs( "msg=5" ) // Actual is the default, no arg required .hasMessage( "Sorry Ava, no brie today, or ever." ); fseq.onDiff() - .hasUrlArgs( "display=Diff", "msg=3" ) + .hasUrlArgs( "display=Diff", "msg=5" ) .hasMessage( "1 - Sorry Ava, no brie today", "1 + Sorry Ava, no brie today, or ever." ); @@ -61,7 +61,7 @@ void search() { fseq.toggleSearch() .search( "brie" ) .hasUrlArgs( - "msg=3", + "msg=5", "search=brie" ) .hasSearchHits( "BEN request : expected", @@ -74,7 +74,7 @@ void search() { fseq.toggleSearch() .search( "or" ) .hasUrlArgs( - "msg=3", + "msg=5", "search=or" ) .hasSearchHits( "CHE response : expected actual", diff --git a/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/AbstractLogViewTest.java b/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/AbstractLogViewTest.java index 999d826e9e..e523e3a831 100644 --- a/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/AbstractLogViewTest.java +++ b/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/AbstractLogViewTest.java @@ -40,7 +40,7 @@ void logFilters() { LogSequence lseq = dseq.logs(); lseq.levels( "WARN", "INFO" ) - .hasUrlArgs( "lv=WARN%2CINFO", "msg=3", "tab=3" ) + .hasUrlArgs( "lv=WARN%2CINFO", "msg=5", "tab=3" ) .hasMessages( "0s Δ 0ms WARN abc message 1", "0.05s Δ 50ms INFO def message 2", @@ -49,13 +49,13 @@ void logFilters() { "2.359s Δ 659ms WARN abc message 6" ); lseq.source( "def" ) - .hasUrlArgs( "lv=WARN%2CINFO", "msg=3", "sf=def", "tab=3" ) + .hasUrlArgs( "lv=WARN%2CINFO", "msg=5", "sf=def", "tab=3" ) .hasMessages( "0.05s Δ 0ms INFO def message 2", "1.7s Δ 1650ms INFO def message 5" ); lseq.message( "5" ) - .hasUrlArgs( "lv=WARN%2CINFO", "mf=5", "msg=3", "sf=def", "tab=3" ) + .hasUrlArgs( "lv=WARN%2CINFO", "mf=5", "msg=5", "sf=def", "tab=3" ) .hasMessages( "1.7s Δ 0ms INFO def message 5" ); // we can deep-link direct to filtered views diff --git a/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/AbstractResidueTest.java b/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/AbstractResidueTest.java index acfb62c392..e33f855842 100644 --- a/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/AbstractResidueTest.java +++ b/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/AbstractResidueTest.java @@ -20,7 +20,7 @@ protected AbstractResidueTest( String url ) { @Test void residue() { dseq.residue() - .hasUrlArgs( "msg=3", "tab=2" ) + .hasUrlArgs( "msg=5", "tab=2" ) .hasPanels( "Psychological state" ) .hasContent( "Psychological state", "Model", diff --git a/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/FileFlowSequenceTest.java b/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/FileFlowSequenceTest.java index 974eed0ba0..3379771795 100644 --- a/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/FileFlowSequenceTest.java +++ b/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/FileFlowSequenceTest.java @@ -26,6 +26,8 @@ void sequenceDiagram() { " BEN request [e ]", " CHE request [e ]", " CHE response [e ap ] 100%", + " CHE request [e ]", + " CHE response [e ]", " BEN response [e a f] 100%" ); } @@ -39,7 +41,7 @@ void basis() { FlowSequence fseq = dseq.flow().onTransmission( "BEN response" ); fseq.onBasis() - .hasUrlArgs( "display=Basis", "msg=3" ) + .hasUrlArgs( "display=Basis", "msg=5" ) .hasMessage( "1 + Sorry Ava, no brie today" ); } diff --git a/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/ServedFlowSequenceTest.java b/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/ServedFlowSequenceTest.java index e808dcf27d..b616de882b 100644 --- a/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/ServedFlowSequenceTest.java +++ b/report/report-core/src/test/java/com/mastercard/test/flow/report/detail/ServedFlowSequenceTest.java @@ -26,6 +26,8 @@ void sequenceDiagram() { " BEN request [eb ]", " CHE request [eb ]", " CHE response [ebap ] 100%", + " CHE request [eb ]", + " CHE response [eb ]", " BEN response [eba f] 100%" ); } @@ -39,7 +41,7 @@ void basis() { FlowSequence fseq = dseq.flow().onTransmission( "BEN response" ); fseq.onBasis() - .hasUrlArgs( "display=Basis", "msg=3" ) + .hasUrlArgs( "display=Basis", "msg=5" ) .hasMessage( "1 - Hi Ava! Here is your brie", "1 + Sorry Ava, no brie today" ); diff --git a/report/report-ng/package.json b/report/report-ng/package.json index ce44a1c87a..c994d75808 100644 --- a/report/report-ng/package.json +++ b/report/report-ng/package.json @@ -6,7 +6,8 @@ "start": "ng serve", "build": "ng build", "watch": "ng build --watch --configuration development", - "test": "ng test --browsers ChromeHeadless --watch=false" + "test": "ng test --browsers ChromeHeadless --watch=false", + "testing": "ng test" }, "private": true, "dependencies": { diff --git a/report/report-ng/projects/report/src/app/basis-fetch.service.spec.ts b/report/report-ng/projects/report/src/app/basis-fetch.service.spec.ts new file mode 100644 index 0000000000..99f7ac5f68 --- /dev/null +++ b/report/report-ng/projects/report/src/app/basis-fetch.service.spec.ts @@ -0,0 +1,212 @@ +import { TestBed } from '@angular/core/testing'; + +import { BasisFetchService, setDistance } from './basis-fetch.service'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Flow, Interaction, empty_flow, empty_interaction, empty_transmission } from './types'; +import { defer, of } from 'rxjs'; +import { Action, empty_action } from './seq-action/seq-action.component'; + +describe('BasisFetchService', () => { + let httpClientSpy: jasmine.SpyObj; + let service: BasisFetchService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']); + service = new BasisFetchService(httpClientSpy); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should request a flow', () => { + httpClientSpy.get.and.returnValue(detailPage(empty_flow)); + + service.get("detail_hash"); + + expect(httpClientSpy.get.calls.count()) + .withContext('one call') + .toBe(1); + expect(httpClientSpy.get.calls.mostRecent().args[0]) + .withContext("request path") + .toBe("detail_hash.html"); + }); + + it('should cope with bad data', () => { + httpClientSpy.get.and.returnValue(of("not a detail page")); + service.get("detail_hash"); + + expect(service.message(action("", "", ""))) + .withContext("nothing there") + .toBe(null); + }); + + it('should cope with malformed json', () => { + httpClientSpy.get.and.returnValue(of("// START_JSON_DATA\n{]\n// END_JSON_DATA")); + service.get("detail_hash"); + + expect(service.message(action("", "", ""))) + .withContext("nothing there") + .toBe(null); + }); + + it('should cope with missing data', () => { + httpClientSpy.get.and.returnValue(of("// START_JSON_DATA\n{}\n// END_JSON_DATA")); + service.get("detail_hash"); + + expect(service.message(action("", "", ""))) + .withContext("nothing there") + .toBe(null); + }); + + it('should match simple interactions', () => { + let flow = flowWithRoot(interaction("AVA", "BEN")); + httpClientSpy.get.and.returnValue(detailPage(flow)); + + service.get("detail_hash"); + + expect(service.message(action("AVA", "BEN", "BEN request"))) + .withContext("matched request") + .toBe("request from AVA to BEN with tags []"); + + expect(service.message(action("BEN", "AVA", "BEN response"))) + .withContext("matched response") + .toBe("response from BEN to AVA with tags []"); + + expect(service.message(action("AVA", "BEN", "BEN foobar"))) + .withContext("bad label") + .toBe(null); + + expect(service.message(action("AVA", "CHE", "CHE request"))) + .withContext("actor mismatch") + .toBe(null); + }); + + it('should cope with tagging', () => { + let flow = flowWithRoot(interaction("AVA", "BEN", "abc")); + httpClientSpy.get.and.returnValue(detailPage(flow)); + + service.get("detail_hash"); + + expect(service.message(action("AVA", "BEN", "BEN request", "abc"))) + .withContext("tag match") + .toBe("request from AVA to BEN with tags [abc]"); + + expect(service.message(action("AVA", "BEN", "BEN request"))) + .withContext("no tags on query") + .toBe("request from AVA to BEN with tags [abc]"); + + expect(service.message(action("AVA", "BEN", "BEN request", "def"))) + .withContext("mismatched tags on query") + .toBe("request from AVA to BEN with tags [abc]"); + }); + + it('should find the best match', () => { + let flow = flowWithRoot(interaction("AVA", "BEN")); + flow.root.children = [ + interaction("BEN", "CHE", "abc"), + interaction("BEN", "CHE", "abc", "def"), + interaction("BEN", "CHE", "def"), + ]; + httpClientSpy.get.and.returnValue(detailPage(flow)); + + service.get("detail_hash"); + + expect(service.message(action("BEN", "CHE", "CHE request", "abc"))) + .withContext("first") + .toBe("request from BEN to CHE with tags [abc]"); + + expect(service.message(action("BEN", "CHE", "CHE request", "def"))) + .withContext("third") + .toBe("request from BEN to CHE with tags [def]"); + + expect(service.message(action("BEN", "CHE", "CHE request", "abc", "def"))) + .withContext("second") + .toBe("request from BEN to CHE with tags [abc,def]"); + }); + + it('should compute set distance correctly', () => { + expect(service).toBeTruthy(); + expect(setDistance([], [])) + .withContext("empty") + .toBe(0); + + expect(setDistance(["a"], [])) + .withContext("single removed") + .toBe(1); + expect(setDistance([], ["a"])) + .withContext("single added") + .toBe(1); + + expect(setDistance(["a", "b", "c"], [])) + .withContext("multi removed") + .toBe(1); + expect(setDistance([], ["a", "b", "c"])) + .withContext("multi added") + .toBe(1); + + expect(setDistance(["a"], ["a"])) + .withContext("single match") + .toBe(0); + expect(setDistance(["a"], ["b"])) + .withContext("single mismatch") + .toBe(1); + + expect(setDistance(["a", "b", "c"], ["a", "b", "c"])) + .withContext("full match") + .toBe(0); + expect(setDistance(["a", "b", "c"], ["a", "b", "c", "d"])) + .withContext("better match") + .toBe(0.25); + expect(setDistance(["a"], ["a", "b", "c", "d"])) + .withContext("slight match") + .toBe(0.75); + expect(setDistance(["a", "w", "x"], ["a", "y", "z"])) + .withContext("worse match") + .toBe(0.8); + expect(setDistance(["a", "b", "c"], ["d", "e", "f"])) + .withContext("disjoint") + .toBe(1); + }); + +}); + +function detailPage(flow: Flow) { + let lines = [ + "blah blah blah", + "// START_JSON_DATA", + ]; + JSON.stringify(flow, null, 2).split("\n").forEach(l => lines.push(l)); + lines.push( + "// END_JSON_DATA", + "blah blah blah", + ); + let page = lines.map((l) => l + "\n").join(""); + return of(page); +} + +function flowWithRoot(interaction: Interaction): Flow { + let flow: Flow = JSON.parse(JSON.stringify(empty_flow)); + flow.root = interaction; + return flow; +} + +function interaction(from: string, to: string, ...tags: string[]) { + let interaction = JSON.parse(JSON.stringify(empty_interaction)); + interaction.requester = from; + interaction.request.full.expect = `request from ${from} to ${to} with tags [${tags}]`; + interaction.responder = to; + interaction.response.full.expect = `response from ${to} to ${from} with tags [${tags}]`; + interaction.tags = tags; + return interaction; +} + +function action(from: string, to: string, label: string, ...tags: string[]): Action { + let action: Action = JSON.parse(JSON.stringify(empty_action)); + action.fromName = from; + action.toName = to; + action.label = label; + action.tags = tags; + return action; +} \ No newline at end of file diff --git a/report/report-ng/projects/report/src/app/basis-fetch.service.ts b/report/report-ng/projects/report/src/app/basis-fetch.service.ts index 41825d3b6a..1ab8cc9745 100644 --- a/report/report-ng/projects/report/src/app/basis-fetch.service.ts +++ b/report/report-ng/projects/report/src/app/basis-fetch.service.ts @@ -38,18 +38,22 @@ export class BasisFetchService { // extract the json content let s = page.indexOf(this.start); let e = page.indexOf(this.end, s); - if (s != undefined && e != undefined) { - // parse it - let json = page.substring(s + this.start.length, e); - let data = JSON.parse(json); - if (isFlow(data)) { - // save the flow - this.basis = toSequence(data); - // notify listeners - this.callbacks.forEach(cb => cb()); - } - else { - console.error("This isn't a flow!", data); + if (s != -1 && e != -1) { + try { + // parse it + let json = page.substring(s + this.start.length, e); + let data = JSON.parse(json); + if (isFlow(data)) { + // save the flow + this.basis = toSequence(data); + // notify listeners + this.callbacks.forEach(cb => cb()); + } + else { + console.error("This isn't a flow!", data); + } + } catch (e) { + console.error("Failed to parse!", e); } } else { @@ -89,11 +93,11 @@ export class BasisFetchService { // now try and find the closest tag match let bestIdx = -1; - let bestTagMatch = -1; + let bestTagMatch = 2; for (let idx = 0; idx < possibles.length; idx++) { const e = possibles[idx]; - let tagMatch = intersectionSize(action.tags, e.tags); - if (tagMatch > bestTagMatch) { + let tagMatch = setDistance(action.tags, e.tags); + if (tagMatch < bestTagMatch) { bestIdx = idx; bestTagMatch = tagMatch; } @@ -104,6 +108,25 @@ export class BasisFetchService { } -function intersectionSize(a: string[], b: string[]) { - return a.filter(b.includes).length; +/** + * Computes a normalised distance metric between two sets + * @param a The first set + * @param b The second set + * @returns A distance metric, ranging from 0 if the sets are identical to 1 if they are disjoint + */ +export function setDistance(a: string[], b: string[]) { + let union = new Set(a); + b.forEach(e => union.add(e)); + + if (union.size == 0) { + return 0; + } + + let left = new Set(a); + b.forEach(e => left.delete(e)); + let right = new Set(b); + a.forEach(e => right.delete(e)); + + let diffSize = left.size + right.size; + return diffSize / union.size; } diff --git a/report/report-ng/projects/report/src/app/detail/detail.component.spec.ts b/report/report-ng/projects/report/src/app/detail/detail.component.spec.ts index 27e85553f7..a13d4c3438 100644 --- a/report/report-ng/projects/report/src/app/detail/detail.component.spec.ts +++ b/report/report-ng/projects/report/src/app/detail/detail.component.spec.ts @@ -4,7 +4,7 @@ import { DetailComponent } from './detail.component'; import { BasisFetchService } from '../basis-fetch.service'; import { MatMenuModule } from '@angular/material/menu'; import { MatListModule } from '@angular/material/list'; -import { DiffType, Display, LogEvent, Options, empty_flow, empty_interaction } from '../types'; +import { DiffType, Display, LogEvent, Options, Residue, empty_flow, empty_interaction } from '../types'; import { MatSidenavModule } from '@angular/material/sidenav'; import { MatTabsModule } from '@angular/material/tabs'; import { MatIconModule } from '@angular/material/icon'; @@ -25,6 +25,8 @@ describe('DetailComponent', () => { DetailComponent, StubLogView, + StubContextView, + StubResidueView, StubFlowSequence, StubTransmission, StubMarkdown, @@ -131,6 +133,22 @@ class StubLogView { @Input() logs: LogEvent[] = []; } +@Component({ + selector: 'app-context-view', + template: '' +}) +class StubContextView { + @Input() context: any = {}; +} + +@Component({ + selector: 'app-residue-view', + template: '' +}) +class StubResidueView { + @Input() residues: Residue[] = []; +} + @Component({ selector: 'app-flow-sequence', template: '' diff --git a/report/report-ng/projects/report/src/app/duct-index-item/duct-index-item.component.spec.ts b/report/report-ng/projects/report/src/app/duct-index-item/duct-index-item.component.spec.ts index 22bdb56cb1..dff32657e3 100644 --- a/report/report-ng/projects/report/src/app/duct-index-item/duct-index-item.component.spec.ts +++ b/report/report-ng/projects/report/src/app/duct-index-item/duct-index-item.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DuctIndexItemComponent } from './duct-index-item.component'; +import { MatListModule } from '@angular/material/list'; describe('DuctIndexItemComponent', () => { let component: DuctIndexItemComponent; @@ -8,7 +9,8 @@ describe('DuctIndexItemComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ DuctIndexItemComponent ] + declarations: [ DuctIndexItemComponent ], + imports: [ MatListModule ], }) .compileComponents(); diff --git a/report/report-ng/projects/report/src/app/duct-index/duct-index.component.spec.ts b/report/report-ng/projects/report/src/app/duct-index/duct-index.component.spec.ts index 0bad6ecd44..96d122f849 100644 --- a/report/report-ng/projects/report/src/app/duct-index/duct-index.component.spec.ts +++ b/report/report-ng/projects/report/src/app/duct-index/duct-index.component.spec.ts @@ -2,6 +2,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DuctIndexComponent } from './duct-index.component'; import { DuctService } from '../duct.service'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatListModule } from '@angular/material/list'; describe('DuctIndexComponent', () => { let component: DuctIndexComponent; @@ -15,6 +17,10 @@ describe('DuctIndexComponent', () => { providers: [ { provide: DuctService, useValue: mockDuctService }, ], + imports: [ + MatToolbarModule, + MatListModule + ], }) .compileComponents(); diff --git a/report/report-ng/projects/report/src/app/index/index.component.spec.ts b/report/report-ng/projects/report/src/app/index/index.component.spec.ts index d961c2ea5c..3e932d122c 100644 --- a/report/report-ng/projects/report/src/app/index/index.component.spec.ts +++ b/report/report-ng/projects/report/src/app/index/index.component.spec.ts @@ -26,6 +26,7 @@ describe('IndexComponent', () => { StubMenu, StubFlowFilter, StubTagSummary, + StubSystemDiagram, ], providers: [ { provide: IndexDataService, useValue: mockIndexData }, @@ -68,4 +69,10 @@ class StubFlowFilter { }) class StubTagSummary { @Input() entries: Entry[] = []; +} +@Component({ + selector: 'app-system-diagram', + template: '' +}) +class StubSystemDiagram { } \ No newline at end of file