@likec4/core 1.1.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/colors/element.d.ts +1 -0
- package/dist/colors/index.d.ts +1 -0
- package/dist/colors/relationships.d.ts +1 -0
- package/dist/errors/errors.spec.d.ts +2 -0
- package/dist/errors/errors.spec.js +69 -0
- package/dist/errors/index.d.ts +1 -0
- package/dist/errors/index.js +7 -1
- package/dist/index.d.ts +1 -0
- package/dist/types/_common.d.ts +1 -0
- package/dist/types/element.d.ts +1 -0
- package/dist/types/expression.d.ts +1 -0
- package/dist/types/index.d.ts +1 -2
- package/dist/types/index.js +2 -3
- package/dist/types/model.d.ts +3 -3
- package/dist/types/opaque.d.ts +1 -0
- package/dist/types/relation.d.ts +1 -0
- package/dist/types/theme.d.ts +1 -0
- package/dist/types/view.d.ts +133 -8
- package/dist/types/view.js +31 -3
- package/dist/utils/fqn.d.ts +1 -0
- package/dist/utils/fqn.spec.d.ts +2 -0
- package/dist/utils/fqn.spec.js +82 -0
- package/dist/utils/guards.d.ts +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/promises.d.ts +1 -0
- package/dist/utils/relations.d.ts +1 -0
- package/dist/utils/relations.spec.d.ts +2 -0
- package/dist/utils/relations.spec.js +210 -0
- package/package.json +6 -6
- package/src/colors/element.ts +80 -0
- package/src/colors/index.ts +12 -0
- package/src/colors/relationships.ts +54 -0
- package/src/errors/errors.spec.ts +88 -0
- package/src/errors/index.ts +120 -0
- package/src/index.ts +9 -0
- package/src/reset.d.ts +2 -0
- package/src/types/_common.ts +5 -0
- package/src/types/element.ts +56 -0
- package/src/types/expression.ts +140 -0
- package/src/types/index.ts +10 -0
- package/src/types/model.ts +15 -0
- package/src/types/opaque.ts +108 -0
- package/src/types/relation.ts +38 -0
- package/src/types/theme.ts +46 -0
- package/src/types/view.ts +263 -0
- package/src/utils/fqn.spec.ts +105 -0
- package/src/utils/fqn.ts +112 -0
- package/src/utils/guards.ts +10 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/promises.ts +9 -0
- package/src/utils/relations.spec.ts +268 -0
- package/src/utils/relations.ts +104 -0
- package/dist/types/computed-view.d.ts +0 -52
- package/dist/types/computed-view.js +0 -1
- package/dist/types/diagram.d.ts +0 -40
- package/dist/types/diagram.js +0 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { Element, Fqn } from '../types'
|
|
3
|
+
import {
|
|
4
|
+
ancestorsFqn,
|
|
5
|
+
commonAncestor,
|
|
6
|
+
compareFqnHierarchically,
|
|
7
|
+
isAncestor,
|
|
8
|
+
isDescendantOf,
|
|
9
|
+
notDescendantOf,
|
|
10
|
+
parentFqn
|
|
11
|
+
} from './fqn'
|
|
12
|
+
|
|
13
|
+
const el = (id: string): Element => ({ id }) as unknown as Element
|
|
14
|
+
|
|
15
|
+
describe('parentFqn', () => {
|
|
16
|
+
it('should return null if no parent', () => {
|
|
17
|
+
expect(parentFqn('a' as Fqn)).toBeNull()
|
|
18
|
+
})
|
|
19
|
+
it('should return parent', () => {
|
|
20
|
+
expect(parentFqn('a.b' as Fqn)).toBe('a')
|
|
21
|
+
expect(parentFqn('a.b.c' as Fqn)).toBe('a.b')
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('ancestorsFqn', () => {
|
|
26
|
+
it('should return empty array if no parent', () => {
|
|
27
|
+
expect(ancestorsFqn('a' as Fqn)).toEqual([])
|
|
28
|
+
})
|
|
29
|
+
it('should return ancestors', () => {
|
|
30
|
+
expect(ancestorsFqn('a.b.c.d.e' as Fqn)).toEqual(['a.b.c.d', 'a.b.c', 'a.b', 'a'])
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('commonAncestor', () => {
|
|
35
|
+
it('should return null if no common ancestor', () => {
|
|
36
|
+
expect(commonAncestor('a' as Fqn, 'b' as Fqn)).toBeNull()
|
|
37
|
+
expect(commonAncestor('a.b' as Fqn, 'c.d' as Fqn)).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('should return common ancestor', () => {
|
|
41
|
+
expect(commonAncestor('a.b' as Fqn, 'a.c' as Fqn)).toBe('a')
|
|
42
|
+
expect(commonAncestor('a.b.c' as Fqn, 'a.b.e' as Fqn)).toBe('a.b')
|
|
43
|
+
expect(commonAncestor('a.b.c.d.e' as Fqn, 'a.b.c.d' as Fqn)).toBe('a.b.c')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('isAncestor', () => {
|
|
48
|
+
it('should return true if ancestor', () => {
|
|
49
|
+
expect(isAncestor('a' as Fqn, 'a.b' as Fqn)).toBe(true)
|
|
50
|
+
expect(isAncestor('a.b' as Fqn, 'a.b.c' as Fqn)).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
it('should return false if not ancestor', () => {
|
|
53
|
+
expect(isAncestor('a' as Fqn, 'b' as Fqn)).toBe(false)
|
|
54
|
+
expect(isAncestor('a.b' as Fqn, 'a' as Fqn)).toBe(false)
|
|
55
|
+
expect(isAncestor('a.b' as Fqn, 'b.a' as Fqn)).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('isDescendantOf', () => {
|
|
60
|
+
const predicate = isDescendantOf(['a', 'b', 'a.b', 'a.b.c'].map(el))
|
|
61
|
+
|
|
62
|
+
it('should return true if isDescendantOf', () => {
|
|
63
|
+
expect(predicate(el('a'))).toBe(true)
|
|
64
|
+
expect(predicate(el('b.c'))).toBe(true)
|
|
65
|
+
expect(predicate(el('a.b.c.d.e'))).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
it('should return false if not descendantOf', () => {
|
|
68
|
+
expect(predicate(el('c'))).toBe(false)
|
|
69
|
+
expect(predicate(el('ac'))).toBe(false)
|
|
70
|
+
expect(predicate(el('d.a.c'))).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('notDescendantOf', () => {
|
|
75
|
+
const predicate = notDescendantOf(['a', 'b', 'a.b', 'a.b.c'].map(el))
|
|
76
|
+
|
|
77
|
+
it('should return true if notDescendantOf', () => {
|
|
78
|
+
expect(predicate(el('c'))).toBe(true)
|
|
79
|
+
expect(predicate(el('ac'))).toBe(true)
|
|
80
|
+
expect(predicate(el('d.a.c'))).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
it('should return false if descendantOf', () => {
|
|
83
|
+
expect(predicate(el('a'))).toBe(false)
|
|
84
|
+
expect(predicate(el('b.c'))).toBe(false)
|
|
85
|
+
expect(predicate(el('a.b.c.d.e'))).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('compareFqnHierarchically', () => {
|
|
90
|
+
it('should compare hierarchically', () => {
|
|
91
|
+
expect(['a', 'b', 'a.b', 'a.b.c', 'a.c.c'].sort(compareFqnHierarchically)).toEqual([
|
|
92
|
+
'a',
|
|
93
|
+
'b',
|
|
94
|
+
'a.b',
|
|
95
|
+
'a.b.c',
|
|
96
|
+
'a.c.c'
|
|
97
|
+
])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should preserve initial order', () => {
|
|
101
|
+
expect(
|
|
102
|
+
['aaa', 'aa', 'a', 'aaa.c', 'aa.b', 'a.c', 'a.b'].sort(compareFqnHierarchically)
|
|
103
|
+
).toEqual(['aaa', 'aa', 'a', 'aaa.c', 'aa.b', 'a.c', 'a.b'])
|
|
104
|
+
})
|
|
105
|
+
})
|
package/src/utils/fqn.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Element, Fqn } from '../types'
|
|
2
|
+
import { isString } from './guards'
|
|
3
|
+
|
|
4
|
+
export function nameFromFqn(fqn: Fqn) {
|
|
5
|
+
const lastDot = fqn.lastIndexOf('.')
|
|
6
|
+
if (lastDot > 0) {
|
|
7
|
+
return fqn.slice(lastDot + 1)
|
|
8
|
+
} else {
|
|
9
|
+
return fqn
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isAncestor<E extends { id: Fqn }>(
|
|
14
|
+
...args: [ancestor: string, another: string] | [ancestor: E, another: E]
|
|
15
|
+
) {
|
|
16
|
+
const ancestor = isString(args[0]) ? args[0] : args[0].id
|
|
17
|
+
const another = isString(args[1]) ? args[1] : args[1].id
|
|
18
|
+
return another.startsWith(ancestor + '.')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isSameHierarchy<E extends { id: Fqn }>(one: E | Fqn, another: E | Fqn) {
|
|
22
|
+
const first = isString(one) ? one : one.id
|
|
23
|
+
const second = isString(another) ? another : another.id
|
|
24
|
+
return first === second || second.startsWith(first + '.') || first.startsWith(second + '.')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isDescendantOf<E extends { id: Fqn }>(ancestors: E[]): (e: E) => boolean {
|
|
28
|
+
const predicates = ancestors.flatMap(a => [(e: E) => e.id === a.id, (e: E) => isAncestor(a, e)])
|
|
29
|
+
return (e: E) => predicates.some(p => p(e))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function notDescendantOf(ancestors: Element[]): (e: Element) => boolean {
|
|
33
|
+
const isDescendant = isDescendantOf(ancestors)
|
|
34
|
+
return (e: Element) => !isDescendant(e)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function commonAncestor(first: Fqn, second: Fqn) {
|
|
38
|
+
const parentA = parentFqn(first)
|
|
39
|
+
const parentB = parentFqn(second)
|
|
40
|
+
if (parentA === parentB) {
|
|
41
|
+
return parentA
|
|
42
|
+
}
|
|
43
|
+
if (!parentA || !parentB) {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const a = first.split('.')
|
|
48
|
+
const b = second.split('.')
|
|
49
|
+
let ancestor: Fqn | null = null
|
|
50
|
+
|
|
51
|
+
while (a.length > 1 && b.length > 1 && !!a[0] && a[0] === b[0]) {
|
|
52
|
+
ancestor = (ancestor ? `${ancestor}.${a[0]}` : a[0]) as Fqn
|
|
53
|
+
a.shift()
|
|
54
|
+
b.shift()
|
|
55
|
+
}
|
|
56
|
+
return ancestor
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parentFqn(fqn: Fqn): Fqn | null {
|
|
60
|
+
const lastDot = fqn.lastIndexOf('.')
|
|
61
|
+
if (lastDot > 0) {
|
|
62
|
+
return fqn.substring(0, lastDot) as Fqn
|
|
63
|
+
}
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get all ancestor elements (i.e. parent, parent’s parent, etc.)
|
|
69
|
+
* (from closest to root)
|
|
70
|
+
*/
|
|
71
|
+
export function ancestorsFqn(fqn: Fqn): Fqn[] {
|
|
72
|
+
const path = fqn.split('.')
|
|
73
|
+
path.pop()
|
|
74
|
+
if (path.length === 0) {
|
|
75
|
+
return []
|
|
76
|
+
}
|
|
77
|
+
return path.reduce((acc, _, idx) => {
|
|
78
|
+
const ancestor = path.slice(0, idx + 1).join('.')
|
|
79
|
+
acc.unshift(ancestor as Fqn)
|
|
80
|
+
return acc
|
|
81
|
+
}, [] as Fqn[])
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Compares two fully qualified names (fqns) hierarchically based on their depth.
|
|
86
|
+
* From parent nodes to leaves
|
|
87
|
+
*
|
|
88
|
+
* @param {string} a - The first fqn to compare.
|
|
89
|
+
* @param {string} b - The second fqn to compare.
|
|
90
|
+
* @returns {number} - 0 if the fqns have the same depth.
|
|
91
|
+
* - Positive number if a is deeper than b.
|
|
92
|
+
* - Negative number if b is deeper than a.
|
|
93
|
+
*/
|
|
94
|
+
export function compareFqnHierarchically<T extends string = string>(a: T, b: T): number {
|
|
95
|
+
const depthA = a.split('.').length
|
|
96
|
+
const depthB = b.split('.').length
|
|
97
|
+
switch (true) {
|
|
98
|
+
case depthA > depthB: {
|
|
99
|
+
return 1
|
|
100
|
+
}
|
|
101
|
+
case depthA < depthB: {
|
|
102
|
+
return -1
|
|
103
|
+
}
|
|
104
|
+
default: {
|
|
105
|
+
return 0
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function compareByFqnHierarchically<T extends { id: string }>(a: T, b: T) {
|
|
111
|
+
return compareFqnHierarchically(a.id, b.id)
|
|
112
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { NonEmptyArray } from '../types'
|
|
2
|
+
export { hasAtLeast } from 'remeda'
|
|
3
|
+
|
|
4
|
+
export function isString(value: unknown): value is string {
|
|
5
|
+
return value != null && typeof value === 'string'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function isNonEmptyArray<A>(arr: ArrayLike<A> | undefined): arr is NonEmptyArray<A> {
|
|
9
|
+
return !!arr && Array.isArray(arr) && arr.length > 0
|
|
10
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { Fqn, Relation, RelationID } from '../types'
|
|
3
|
+
import { compareRelations, Relations } from './relations'
|
|
4
|
+
|
|
5
|
+
const { isAnyBetween, isAnyInOut, isBetween, isIncoming, isInside, isOutgoing } = Relations
|
|
6
|
+
|
|
7
|
+
const relations = [
|
|
8
|
+
{
|
|
9
|
+
id: 'customer:cloud.frontend.dashboard' as RelationID,
|
|
10
|
+
source: 'customer' as Fqn,
|
|
11
|
+
target: 'cloud.frontend.dashboard' as Fqn,
|
|
12
|
+
title: ''
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: 'support:cloud.frontend.adminPanel' as RelationID,
|
|
16
|
+
source: 'support' as Fqn,
|
|
17
|
+
target: 'cloud.frontend.adminPanel' as Fqn,
|
|
18
|
+
title: ''
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 'cloud.backend.storage:amazon.s3' as RelationID,
|
|
22
|
+
source: 'cloud.backend.storage' as Fqn,
|
|
23
|
+
target: 'amazon.s3' as Fqn,
|
|
24
|
+
title: ''
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'amazon.api:cloud.backend.graphql' as RelationID,
|
|
28
|
+
source: 'amazon.api' as Fqn,
|
|
29
|
+
target: 'cloud.backend.graphql' as Fqn,
|
|
30
|
+
title: ''
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'cloud.backend.graphql:cloud.backend.storage' as RelationID,
|
|
34
|
+
source: 'cloud.backend.graphql' as Fqn,
|
|
35
|
+
target: 'cloud.backend.storage' as Fqn,
|
|
36
|
+
title: ''
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'cloud.frontend.dashboard:cloud.backend.graphql' as RelationID,
|
|
40
|
+
source: 'cloud.frontend.dashboard' as Fqn,
|
|
41
|
+
target: 'cloud.backend.graphql' as Fqn,
|
|
42
|
+
title: ''
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'cloud.frontend.adminPanel:cloud.backend.graphql' as RelationID,
|
|
46
|
+
source: 'cloud.frontend.adminPanel' as Fqn,
|
|
47
|
+
target: 'cloud.backend.graphql' as Fqn,
|
|
48
|
+
title: ''
|
|
49
|
+
}
|
|
50
|
+
] satisfies Relation[]
|
|
51
|
+
|
|
52
|
+
describe('relation predicates', () => {
|
|
53
|
+
const expectRelations = (predicate: (relation: Relation) => boolean) =>
|
|
54
|
+
expect(relations.filter(predicate).map(r => r.id))
|
|
55
|
+
|
|
56
|
+
const customer = 'customer' as Fqn
|
|
57
|
+
const cloud = 'cloud' as Fqn
|
|
58
|
+
const frontend = 'cloud.frontend' as Fqn
|
|
59
|
+
const backend = 'cloud.backend' as Fqn
|
|
60
|
+
|
|
61
|
+
it('isBetween: cloud.frontend -> cloud.backend', () => {
|
|
62
|
+
expectRelations(isBetween(frontend, backend)).toEqual([
|
|
63
|
+
'cloud.frontend.dashboard:cloud.backend.graphql',
|
|
64
|
+
'cloud.frontend.adminPanel:cloud.backend.graphql'
|
|
65
|
+
])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('isBetween: cloud.frontend.dashboard -> cloud.backend', () => {
|
|
69
|
+
expectRelations(isBetween('cloud.frontend.dashboard' as Fqn, backend)).toEqual([
|
|
70
|
+
'cloud.frontend.dashboard:cloud.backend.graphql'
|
|
71
|
+
])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('isBetween: cloud.backend -> cloud.frontend', () => {
|
|
75
|
+
expectRelations(isBetween(backend, frontend)).toEqual([])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('isBetween: cloud.backend -> cloud.backend', () => {
|
|
79
|
+
expectRelations(isBetween(backend, backend)).toEqual([
|
|
80
|
+
'cloud.backend.graphql:cloud.backend.storage'
|
|
81
|
+
])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('isBetween: customer -> cloud', () => {
|
|
85
|
+
expectRelations(isBetween(customer, cloud)).toEqual(['customer:cloud.frontend.dashboard'])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('isIncoming: customer', () => {
|
|
89
|
+
expectRelations(isIncoming(customer)).toEqual([])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('isIncoming: cloud', () => {
|
|
93
|
+
expectRelations(isIncoming(cloud)).toEqual([
|
|
94
|
+
'customer:cloud.frontend.dashboard',
|
|
95
|
+
'support:cloud.frontend.adminPanel',
|
|
96
|
+
'amazon.api:cloud.backend.graphql'
|
|
97
|
+
])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('isIncoming: cloud.frontend', () => {
|
|
101
|
+
expectRelations(isIncoming(frontend)).toEqual([
|
|
102
|
+
'customer:cloud.frontend.dashboard',
|
|
103
|
+
'support:cloud.frontend.adminPanel'
|
|
104
|
+
])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('isIncoming: cloud.frontend.dashboard', () => {
|
|
108
|
+
expectRelations(isIncoming('cloud.frontend.dashboard' as Fqn)).toEqual([
|
|
109
|
+
'customer:cloud.frontend.dashboard'
|
|
110
|
+
])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('isOutgoing: cloud', () => {
|
|
114
|
+
expectRelations(isOutgoing(cloud)).toEqual(['cloud.backend.storage:amazon.s3'])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('isOutgoing: cloud.frontend', () => {
|
|
118
|
+
expectRelations(isOutgoing(frontend)).toEqual([
|
|
119
|
+
'cloud.frontend.dashboard:cloud.backend.graphql',
|
|
120
|
+
'cloud.frontend.adminPanel:cloud.backend.graphql'
|
|
121
|
+
])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('isOutgoing: cloud.backend.storage', () => {
|
|
125
|
+
expectRelations(isOutgoing('cloud.backend.storage' as Fqn)).toEqual([
|
|
126
|
+
'cloud.backend.storage:amazon.s3'
|
|
127
|
+
])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('isInside: cloud', () => {
|
|
131
|
+
expectRelations(isInside(cloud)).toEqual([
|
|
132
|
+
'cloud.backend.graphql:cloud.backend.storage',
|
|
133
|
+
'cloud.frontend.dashboard:cloud.backend.graphql',
|
|
134
|
+
'cloud.frontend.adminPanel:cloud.backend.graphql'
|
|
135
|
+
])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('isInside: cloud.frontend', () => {
|
|
139
|
+
expectRelations(isInside(frontend)).toEqual([])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('isInside: cloud.backend', () => {
|
|
143
|
+
expectRelations(isInside(backend)).toEqual(['cloud.backend.graphql:cloud.backend.storage'])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('isAnyInOut: customer', () => {
|
|
147
|
+
expectRelations(isAnyInOut(customer)).toEqual(['customer:cloud.frontend.dashboard'])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('isAnyInOut: cloud', () => {
|
|
151
|
+
expectRelations(isAnyInOut(cloud)).toEqual([
|
|
152
|
+
'customer:cloud.frontend.dashboard',
|
|
153
|
+
'support:cloud.frontend.adminPanel',
|
|
154
|
+
'cloud.backend.storage:amazon.s3',
|
|
155
|
+
'amazon.api:cloud.backend.graphql'
|
|
156
|
+
])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('isAnyBetween: cloud and amazon', () => {
|
|
160
|
+
expectRelations(isAnyBetween(cloud, 'amazon' as Fqn)).toEqual([
|
|
161
|
+
'cloud.backend.storage:amazon.s3',
|
|
162
|
+
'amazon.api:cloud.backend.graphql'
|
|
163
|
+
])
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('compareRelations', () => {
|
|
168
|
+
function rel(source: string, target: string) {
|
|
169
|
+
return {
|
|
170
|
+
source,
|
|
171
|
+
target
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function sorted(...relations: Array<{ source: string; target: string }>) {
|
|
175
|
+
return relations.sort(compareRelations).map(r => r.source + ' -> ' + r.target)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
it('should sort by source and target', () => {
|
|
179
|
+
expect(sorted(rel('customer', 'cloud.ui'), rel('customer', 'cloud'))).toEqual([
|
|
180
|
+
'customer -> cloud',
|
|
181
|
+
'customer -> cloud.ui'
|
|
182
|
+
])
|
|
183
|
+
|
|
184
|
+
expect(
|
|
185
|
+
sorted(
|
|
186
|
+
rel('1', '2.1'),
|
|
187
|
+
rel('1', '2'),
|
|
188
|
+
rel('1.1.1', '2'),
|
|
189
|
+
rel('1.1.1', '2.1'),
|
|
190
|
+
rel('1.1', '2')
|
|
191
|
+
)
|
|
192
|
+
).toEqual(['1 -> 2', '1 -> 2.1', '1.1 -> 2', '1.1.1 -> 2', '1.1.1 -> 2.1'])
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('should sort by parent', () => {
|
|
196
|
+
expect(
|
|
197
|
+
sorted(
|
|
198
|
+
// same parent
|
|
199
|
+
rel('cloud.1.1', 'cloud.2.1'),
|
|
200
|
+
rel('cloud.1.1', 'cloud.2'),
|
|
201
|
+
rel('cloud.1', 'cloud.2.1'),
|
|
202
|
+
rel('cloud.1', 'cloud.2'),
|
|
203
|
+
// no same parent
|
|
204
|
+
rel('cloud.1.1', 'aws.1'),
|
|
205
|
+
rel('cloud.1', 'aws.1'),
|
|
206
|
+
rel('cloud', 'aws')
|
|
207
|
+
)
|
|
208
|
+
).toEqual([
|
|
209
|
+
'cloud -> aws',
|
|
210
|
+
'cloud.1 -> aws.1',
|
|
211
|
+
'cloud.1.1 -> aws.1',
|
|
212
|
+
'cloud.1 -> cloud.2',
|
|
213
|
+
'cloud.1 -> cloud.2.1',
|
|
214
|
+
'cloud.1.1 -> cloud.2',
|
|
215
|
+
'cloud.1.1 -> cloud.2.1'
|
|
216
|
+
])
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('should sort by ancestors', () => {
|
|
220
|
+
expect(
|
|
221
|
+
sorted(
|
|
222
|
+
// same parent 2nd level
|
|
223
|
+
rel('cloud.api.1', 'cloud.api.2'),
|
|
224
|
+
rel('cloud.ui.1', 'cloud.ui.2'),
|
|
225
|
+
rel('cloud.ui.2', 'cloud.ui.1'),
|
|
226
|
+
// same ancestor cloud
|
|
227
|
+
rel('cloud.ui.1', 'cloud.api.1'),
|
|
228
|
+
rel('cloud.ui.2', 'cloud.api.2'),
|
|
229
|
+
rel('cloud.ui', 'cloud.api.1'),
|
|
230
|
+
// same parent 1st level
|
|
231
|
+
rel('cloud.ui', 'cloud.api'),
|
|
232
|
+
// no same parent
|
|
233
|
+
rel('cloud.api.1', 'aws.1'),
|
|
234
|
+
rel('cloud.api', 'aws.1'),
|
|
235
|
+
rel('cloud.api', 'aws'),
|
|
236
|
+
rel('cloud.ui.1', 'aws.1'),
|
|
237
|
+
rel('cloud.ui', 'aws'),
|
|
238
|
+
rel('cloud', 'aws')
|
|
239
|
+
)
|
|
240
|
+
).toEqual([
|
|
241
|
+
'cloud -> aws',
|
|
242
|
+
'cloud.api -> aws',
|
|
243
|
+
'cloud.ui -> aws',
|
|
244
|
+
'cloud.api -> aws.1',
|
|
245
|
+
'cloud.api.1 -> aws.1',
|
|
246
|
+
'cloud.ui.1 -> aws.1',
|
|
247
|
+
'cloud.ui -> cloud.api',
|
|
248
|
+
'cloud.ui -> cloud.api.1',
|
|
249
|
+
'cloud.ui.1 -> cloud.api.1',
|
|
250
|
+
'cloud.ui.2 -> cloud.api.2',
|
|
251
|
+
'cloud.api.1 -> cloud.api.2',
|
|
252
|
+
'cloud.ui.1 -> cloud.ui.2',
|
|
253
|
+
'cloud.ui.2 -> cloud.ui.1'
|
|
254
|
+
])
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('should sort relations from example', () => {
|
|
258
|
+
expect(sorted(...relations)).toEqual([
|
|
259
|
+
'customer -> cloud.frontend.dashboard',
|
|
260
|
+
'support -> cloud.frontend.adminPanel',
|
|
261
|
+
'amazon.api -> cloud.backend.graphql',
|
|
262
|
+
'cloud.backend.storage -> amazon.s3',
|
|
263
|
+
'cloud.frontend.dashboard -> cloud.backend.graphql',
|
|
264
|
+
'cloud.frontend.adminPanel -> cloud.backend.graphql',
|
|
265
|
+
'cloud.backend.graphql -> cloud.backend.storage'
|
|
266
|
+
])
|
|
267
|
+
})
|
|
268
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Fqn } from '../types'
|
|
2
|
+
import { commonAncestor, compareFqnHierarchically, isAncestor } from './fqn'
|
|
3
|
+
|
|
4
|
+
type Relation = {
|
|
5
|
+
source: string
|
|
6
|
+
target: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type RelationPredicate = (rel: Relation) => boolean
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Compares two relations hierarchically.
|
|
13
|
+
* From the most general (implicit) to the most specific.
|
|
14
|
+
*/
|
|
15
|
+
export const compareRelations = <T extends { source: string; target: string }>(a: T, b: T) => {
|
|
16
|
+
const parentA = commonAncestor(a.source as Fqn, a.target as Fqn)
|
|
17
|
+
const parentB = commonAncestor(b.source as Fqn, b.target as Fqn)
|
|
18
|
+
if (parentA && !parentB) {
|
|
19
|
+
return 1
|
|
20
|
+
}
|
|
21
|
+
if (!parentA && parentB) {
|
|
22
|
+
return -1
|
|
23
|
+
}
|
|
24
|
+
const compareParents = parentA && parentB ? compareFqnHierarchically(parentA, parentB) : 0
|
|
25
|
+
if (compareParents === 0) {
|
|
26
|
+
const compareSource = compareFqnHierarchically(a.source, b.source)
|
|
27
|
+
if (compareSource !== 0) {
|
|
28
|
+
return compareSource
|
|
29
|
+
}
|
|
30
|
+
return compareFqnHierarchically(a.target, b.target)
|
|
31
|
+
}
|
|
32
|
+
return compareParents
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isInside = (parent: Fqn): RelationPredicate => {
|
|
36
|
+
const prefix = parent + '.'
|
|
37
|
+
return (rel: Relation) => {
|
|
38
|
+
return rel.source.startsWith(prefix) && rel.target.startsWith(prefix)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isBetween = (source: Fqn, target: Fqn): RelationPredicate => {
|
|
43
|
+
const sourcePrefix = source + '.'
|
|
44
|
+
const targetPrefix = target + '.'
|
|
45
|
+
return (rel: Relation) => {
|
|
46
|
+
return (
|
|
47
|
+
(rel.source === source || (rel.source + '.').startsWith(sourcePrefix))
|
|
48
|
+
&& (rel.target === target || (rel.target + '.').startsWith(targetPrefix))
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isAnyBetween = (source: Fqn, target: Fqn): RelationPredicate => {
|
|
54
|
+
const predicates = [isBetween(source, target), isBetween(target, source)]
|
|
55
|
+
return (rel) => predicates.some(p => p(rel))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isIncoming = (target: Fqn): RelationPredicate => {
|
|
59
|
+
const targetPrefix = target + '.'
|
|
60
|
+
return (rel: Relation) => {
|
|
61
|
+
return (
|
|
62
|
+
!(rel.source + '.').startsWith(targetPrefix)
|
|
63
|
+
&& (rel.target === target || (rel.target + '.').startsWith(targetPrefix))
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const isOutgoing = (source: Fqn): RelationPredicate => {
|
|
69
|
+
const sourcePrefix = source + '.'
|
|
70
|
+
return (rel: Relation) => {
|
|
71
|
+
return (
|
|
72
|
+
(rel.source === source || (rel.source + '.').startsWith(sourcePrefix))
|
|
73
|
+
&& !(rel.target + '.').startsWith(sourcePrefix)
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const isAnyInOut = (source: Fqn): RelationPredicate => {
|
|
79
|
+
const predicates = [isIncoming(source), isOutgoing(source)]
|
|
80
|
+
return (rel: Relation) => {
|
|
81
|
+
return predicates.some(p => p(rel))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const hasRelation = <R extends { source: Fqn; target: Fqn }>(rel: R) => {
|
|
86
|
+
return <E extends { id: Fqn }>(element: E) => {
|
|
87
|
+
return (
|
|
88
|
+
rel.source === element.id
|
|
89
|
+
|| rel.target === element.id
|
|
90
|
+
|| isAncestor(element.id, rel.source)
|
|
91
|
+
|| isAncestor(element.id, rel.target)
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const Relations = {
|
|
97
|
+
isInside,
|
|
98
|
+
isBetween,
|
|
99
|
+
isAnyBetween,
|
|
100
|
+
isIncoming,
|
|
101
|
+
isOutgoing,
|
|
102
|
+
isAnyInOut,
|
|
103
|
+
hasRelation
|
|
104
|
+
} as const
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import type { IconUrl, NonEmptyArray } from './_common';
|
|
2
|
-
import type { ElementKind, ElementShape, ElementStyle, Fqn, Tag } from './element';
|
|
3
|
-
import type { Opaque } from './opaque';
|
|
4
|
-
import type { RelationID, RelationshipArrowType, RelationshipLineType } from './relation';
|
|
5
|
-
import type { ThemeColor } from './theme';
|
|
6
|
-
import type { BasicElementView, ViewID, ViewRuleAutoLayout } from './view';
|
|
7
|
-
export type NodeId = Fqn;
|
|
8
|
-
export type EdgeId = Opaque<string, 'EdgeId'>;
|
|
9
|
-
export interface ComputedNode {
|
|
10
|
-
id: NodeId;
|
|
11
|
-
kind: ElementKind;
|
|
12
|
-
parent: NodeId | null;
|
|
13
|
-
title: string;
|
|
14
|
-
description: string | null;
|
|
15
|
-
technology: string | null;
|
|
16
|
-
tags: NonEmptyArray<Tag> | null;
|
|
17
|
-
links: NonEmptyArray<string> | null;
|
|
18
|
-
children: NodeId[];
|
|
19
|
-
inEdges: EdgeId[];
|
|
20
|
-
outEdges: EdgeId[];
|
|
21
|
-
shape: ElementShape;
|
|
22
|
-
/**
|
|
23
|
-
* @deprecated Use `style` instead
|
|
24
|
-
*/
|
|
25
|
-
color: ThemeColor;
|
|
26
|
-
/**
|
|
27
|
-
* @deprecated Use `style` instead
|
|
28
|
-
*/
|
|
29
|
-
icon?: IconUrl;
|
|
30
|
-
style: ElementStyle;
|
|
31
|
-
navigateTo?: ViewID;
|
|
32
|
-
level: number;
|
|
33
|
-
depth?: number;
|
|
34
|
-
}
|
|
35
|
-
export interface ComputedEdge {
|
|
36
|
-
id: EdgeId;
|
|
37
|
-
parent: NodeId | null;
|
|
38
|
-
source: NodeId;
|
|
39
|
-
target: NodeId;
|
|
40
|
-
label: string | null;
|
|
41
|
-
relations: RelationID[];
|
|
42
|
-
color?: ThemeColor;
|
|
43
|
-
line?: RelationshipLineType;
|
|
44
|
-
head?: RelationshipArrowType;
|
|
45
|
-
tail?: RelationshipArrowType;
|
|
46
|
-
}
|
|
47
|
-
export interface ComputedView extends Omit<BasicElementView, 'rules'> {
|
|
48
|
-
readonly extends?: ViewID;
|
|
49
|
-
readonly autoLayout: ViewRuleAutoLayout['autoLayout'];
|
|
50
|
-
readonly nodes: ComputedNode[];
|
|
51
|
-
readonly edges: ComputedEdge[];
|
|
52
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|