@operato/layout 10.29.46 → 10.29.49
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/CHANGELOG.md +18 -0
- package/coverage/lcov-report/index.html +222 -177
- package/coverage/lcov.info +37542 -35151
- package/dist/src/actions/entity360-door.d.ts +28 -0
- package/dist/src/actions/entity360-door.js +45 -0
- package/dist/src/actions/entity360-door.js.map +1 -1
- package/dist/src/actions/entity360-registry.d.ts +13 -3
- package/dist/src/actions/entity360-registry.js +49 -8
- package/dist/src/actions/entity360-registry.js.map +1 -1
- package/dist/src/actions/entity360.d.ts +2 -0
- package/dist/src/actions/entity360.js +4 -0
- package/dist/src/actions/entity360.js.map +1 -1
- package/dist/src/actions/id-lens.d.ts +67 -0
- package/dist/src/actions/id-lens.js +51 -0
- package/dist/src/actions/id-lens.js.map +1 -0
- package/dist/src/actions/layout.d.ts +5 -0
- package/dist/src/actions/layout.js.map +1 -1
- package/dist/src/components/ox-entity360-dock.d.ts +18 -2
- package/dist/src/components/ox-entity360-dock.js +39 -17
- package/dist/src/components/ox-entity360-dock.js.map +1 -1
- package/dist/src/components/ox-id-lens.d.ts +58 -0
- package/dist/src/components/ox-id-lens.js +362 -0
- package/dist/src/components/ox-id-lens.js.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/layouts/ox-aside-bar.js +134 -6
- package/dist/src/layouts/ox-aside-bar.js.map +1 -1
- package/dist/test/entity360-companion.test.d.ts +1 -0
- package/dist/test/entity360-companion.test.js +127 -0
- package/dist/test/entity360-companion.test.js.map +1 -0
- package/dist/test/entity360-kind.test.js +136 -10
- package/dist/test/entity360-kind.test.js.map +1 -1
- package/dist/test/entity360-section.test.js +31 -1
- package/dist/test/entity360-section.test.js.map +1 -1
- package/dist/test/entity360.test.js +29 -6
- package/dist/test/entity360.test.js.map +1 -1
- package/dist/test/id-lens.test.d.ts +2 -0
- package/dist/test/id-lens.test.js +162 -0
- package/dist/test/id-lens.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +11 -6
- package/test/entity360-companion.test.ts +110 -0
- package/test/entity360-kind.test.ts +145 -11
- package/test/entity360-section.test.ts +34 -1
- package/test/entity360.test.ts +31 -6
- package/test/id-lens.test.ts +198 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { expect, fixture, elementUpdated } from '@open-wc/testing'
|
|
2
|
+
import { css, html, LitElement } from 'lit'
|
|
3
|
+
import { setViewport } from '@web/test-runner-commands'
|
|
4
|
+
import '../src/index.js'
|
|
5
|
+
import { appendViewpart, removeViewpart, showEntity360, hideEntity360, VIEWPART_POSITION, VIEWPART_LEVEL, type OxEntity360Dock } from '../src/index.js'
|
|
6
|
+
|
|
7
|
+
class CompanionProbe extends LitElement {
|
|
8
|
+
static styles = css`:host { display: flex; flex-direction: column; width: 420px; height: 100%; }`
|
|
9
|
+
connections = 0
|
|
10
|
+
connectedCallback() { super.connectedCallback(); this.connections++ }
|
|
11
|
+
render() { return html`<textarea aria-label="Conversation"></textarea><button>Send</button>` }
|
|
12
|
+
}
|
|
13
|
+
customElements.define('test-companion-probe', CompanionProbe)
|
|
14
|
+
const owner = {}
|
|
15
|
+
const labels = { close:'Close', expand:'Expand', collapse:'Collapse', resize:'Resize', back:'Back' }
|
|
16
|
+
const settle = () => new Promise(resolve => setTimeout(resolve, 60))
|
|
17
|
+
let bar: HTMLElement, companion: CompanionProbe, dock: OxEntity360Dock
|
|
18
|
+
const input = () => dock.shadowRoot!.querySelector('ox-entity360-content')!.shadowRoot!.querySelector('input')!
|
|
19
|
+
const textarea = () => companion.shadowRoot!.querySelector('textarea')!
|
|
20
|
+
const openCompanion = () => appendViewpart({ name:'test-companion', position:VIEWPART_POSITION.ASIDEBAR, viewpart:{ show:true, persistent:true, level:VIEWPART_LEVEL.TOPMOST, expansionCompanion:{labelKey:'test.conversation',defaultLabel:'Conversation'}, template:html`${companion}` } })
|
|
21
|
+
|
|
22
|
+
describe('expanded entity workspace with a supporting dock', () => {
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
await setViewport({ width:1440,height:900 })
|
|
25
|
+
bar = await fixture(html`<ox-aside-bar></ox-aside-bar>`)
|
|
26
|
+
companion = document.createElement('test-companion-probe') as CompanionProbe
|
|
27
|
+
openCompanion()
|
|
28
|
+
showEntity360(owner, { key:'one', title:'Contract', context:'same subject', labels, content:html`<input value="initial">`, onClose:()=>hideEntity360(owner) })
|
|
29
|
+
await settle()
|
|
30
|
+
dock = bar.shadowRoot!.querySelector('ox-entity360-dock') as OxEntity360Dock
|
|
31
|
+
})
|
|
32
|
+
afterEach(async () => { hideEntity360(owner); removeViewpart('test-companion'); await setViewport({width:800,height:600}) })
|
|
33
|
+
|
|
34
|
+
it('shares the expanded width without recreating either editor or changing its values', async () => {
|
|
35
|
+
const originalInput = input(), originalChat = textarea(), connections = companion.connections
|
|
36
|
+
originalInput.value='allocation draft'; originalChat.value='question draft'
|
|
37
|
+
dock.expanded=true; await settle()
|
|
38
|
+
expect(bar.hasAttribute('shared-fullscreen')).to.equal(true)
|
|
39
|
+
expect(bar.getAttribute('aria-modal')).to.equal('true')
|
|
40
|
+
expect(dock.shadowRoot!.querySelector('[aria-modal]')).to.equal(null)
|
|
41
|
+
const main = dock.getBoundingClientRect(), chat = companion.getBoundingClientRect()
|
|
42
|
+
expect(main.width).to.equal(1020); expect(chat.width).to.equal(420)
|
|
43
|
+
expect(main.right).to.equal(chat.left)
|
|
44
|
+
dock.expanded=false; await settle()
|
|
45
|
+
expect(input()).to.equal(originalInput); expect(textarea()).to.equal(originalChat)
|
|
46
|
+
expect(originalInput.value).to.equal('allocation draft'); expect(originalChat.value).to.equal('question draft')
|
|
47
|
+
expect(companion.connections).to.equal(connections)
|
|
48
|
+
expect(bar.hasAttribute('shared-fullscreen')).to.equal(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('Tab crosses from the last main control into the conversation and returns with Shift+Tab', async () => {
|
|
52
|
+
dock.expanded=true; await settle()
|
|
53
|
+
input().focus()
|
|
54
|
+
input().dispatchEvent(new KeyboardEvent('keydown',{key:'Tab',bubbles:true,composed:true,cancelable:true}))
|
|
55
|
+
expect(companion.shadowRoot!.activeElement).to.equal(textarea())
|
|
56
|
+
textarea().dispatchEvent(new KeyboardEvent('keydown',{key:'Tab',shiftKey:true,bubbles:true,composed:true,cancelable:true}))
|
|
57
|
+
expect(input().getRootNode() instanceof ShadowRoot && (input().getRootNode() as ShadowRoot).activeElement).to.equal(input())
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('narrowing keeps the focused conversation visible, then switches panes without disconnecting it', async () => {
|
|
61
|
+
dock.expanded=true; await settle()
|
|
62
|
+
textarea().value='still writing'; textarea().focus()
|
|
63
|
+
expect((bar as any).pane).to.equal('companion', 'focused pane remembered')
|
|
64
|
+
const connections=companion.connections
|
|
65
|
+
await setViewport({width:390,height:844}); await settle()
|
|
66
|
+
expect(companion.getBoundingClientRect().width).to.equal(390)
|
|
67
|
+
expect(dock.getBoundingClientRect().width).to.equal(0)
|
|
68
|
+
const switches=bar.shadowRoot!.querySelectorAll<HTMLButtonElement>('.workspace-switch button')
|
|
69
|
+
switches[0].click(); await settle()
|
|
70
|
+
expect(dock.getBoundingClientRect().width).to.equal(390)
|
|
71
|
+
expect(companion.getBoundingClientRect().width).to.equal(0)
|
|
72
|
+
switches[1].click(); await settle()
|
|
73
|
+
expect(textarea().value).to.equal('still writing')
|
|
74
|
+
expect(companion.connections).to.equal(connections)
|
|
75
|
+
await setViewport({width:1440,height:900}); await settle()
|
|
76
|
+
expect(dock.getBoundingClientRect().width).to.equal(1020)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('closing the companion gives the full width back; expansion does not open it again', async () => {
|
|
80
|
+
dock.expanded=true; await settle()
|
|
81
|
+
removeViewpart('test-companion'); await settle()
|
|
82
|
+
expect(dock.contained).to.equal(false)
|
|
83
|
+
expect(dock.getBoundingClientRect().width).to.equal(1440)
|
|
84
|
+
dock.expanded=false; await elementUpdated(dock)
|
|
85
|
+
dock.expanded=true; await settle()
|
|
86
|
+
expect(bar.shadowRoot!.querySelector('test-companion-probe')).to.equal(null)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('explicitly opening a companion in a narrow expanded workspace reveals it', async () => {
|
|
90
|
+
removeViewpart('test-companion'); await settle()
|
|
91
|
+
dock.expanded=true
|
|
92
|
+
await setViewport({width:390,height:844}); await settle()
|
|
93
|
+
openCompanion(); await settle()
|
|
94
|
+
expect(companion.getBoundingClientRect().width).to.equal(390)
|
|
95
|
+
expect(dock.getBoundingClientRect().width).to.equal(0)
|
|
96
|
+
expect(bar.shadowRoot!.activeElement).to.equal(bar.shadowRoot!.querySelectorAll('.workspace-switch button')[1])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('a modal above the shared workspace keeps ownership of Tab and Escape', async () => {
|
|
100
|
+
dock.expanded=true; await settle()
|
|
101
|
+
const state=history.state
|
|
102
|
+
history.replaceState({...state,overlay:{name:'confirmation'}},'')
|
|
103
|
+
const tab=new KeyboardEvent('keydown',{key:'Tab',bubbles:true,composed:true,cancelable:true})
|
|
104
|
+
input().dispatchEvent(tab)
|
|
105
|
+
expect(tab.defaultPrevented).to.equal(false)
|
|
106
|
+
input().dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,composed:true,cancelable:true}))
|
|
107
|
+
expect(dock.isConnected).to.equal(true)
|
|
108
|
+
history.replaceState(state,'')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, fixture } from '@open-wc/testing'
|
|
2
|
-
import { html, render } from 'lit'
|
|
2
|
+
import { html, render, LitElement } from 'lit'
|
|
3
|
+
import { OxPrompt } from '@operato/popup/ox-prompt.js'
|
|
3
4
|
import { store, UPDATE_CONTEXT } from '@operato/shell'
|
|
4
5
|
import '../src/index.js'
|
|
5
6
|
import {
|
|
@@ -7,8 +8,10 @@ import {
|
|
|
7
8
|
entity360Link,
|
|
8
9
|
onEntity360Change,
|
|
9
10
|
openEntity360Kind,
|
|
10
|
-
redrawEntity360,
|
|
11
11
|
registerEntity360Kind,
|
|
12
|
+
registerEntity360Section,
|
|
13
|
+
setEntity360Value,
|
|
14
|
+
ENTITY360_UNKEPT,
|
|
12
15
|
type Entity360Subject,
|
|
13
16
|
type OxEntity360Dock
|
|
14
17
|
} from '../src/index.js'
|
|
@@ -29,6 +32,15 @@ const back = (go: () => void = () => history.back()) =>
|
|
|
29
32
|
go()
|
|
30
33
|
})
|
|
31
34
|
let bar: HTMLElement
|
|
35
|
+
/**
|
|
36
|
+
* Links drawn straight into the page. `fixture` waits for an animation frame, and a page the runner keeps in the
|
|
37
|
+
* background (files run side by side) may not get one before the test times out.
|
|
38
|
+
*/
|
|
39
|
+
const drawn = (template: unknown) => {
|
|
40
|
+
const host = document.body.appendChild(document.createElement('div'))
|
|
41
|
+
render(template, host)
|
|
42
|
+
return host
|
|
43
|
+
}
|
|
32
44
|
const dock = () => bar.shadowRoot!.querySelector('ox-entity360-dock') as OxEntity360Dock
|
|
33
45
|
const subject = () => (store.getState() as any).route?.context?.subject
|
|
34
46
|
const address = () => new URLSearchParams(location.search).get('f')
|
|
@@ -36,8 +48,29 @@ const address = () => new URLSearchParams(location.search).get('f')
|
|
|
36
48
|
registerEntity360Kind({ kind: 'kx-order', typenames: [], title: s => `order ${s.id}`, assistantSubject: true })
|
|
37
49
|
registerEntity360Kind({ kind: 'kx-place', typenames: [], title: s => `place ${s.id}`, assistantSubject: true })
|
|
38
50
|
registerEntity360Kind({ kind: 'kx-quiet', typenames: [], title: s => `quiet ${s.id}` })
|
|
39
|
-
|
|
40
|
-
|
|
51
|
+
registerEntity360Kind({ kind: 'kx-late', typenames: [], title: s => s.value.name || s.id })
|
|
52
|
+
|
|
53
|
+
/* A kind whose one identifier is its name, the way a lot is (ADR-0067 decision 6). */
|
|
54
|
+
registerEntity360Kind({ kind: 'kx-lot', typenames: ['KxLot'], id: value => value.name, title: s => s.value.name || s.id })
|
|
55
|
+
|
|
56
|
+
/** A section the way modules write one: it takes the subject and reads whenever the subject changes. */
|
|
57
|
+
const reads: { section: Element; subject: Entity360Subject }[] = []
|
|
58
|
+
class KxSection extends LitElement {
|
|
59
|
+
static properties = { subject: { attribute: false } }
|
|
60
|
+
subject?: Entity360Subject
|
|
61
|
+
createRenderRoot() {
|
|
62
|
+
return this
|
|
63
|
+
}
|
|
64
|
+
updated(changed: Map<string, unknown>) {
|
|
65
|
+
if (changed.has('subject') && this.subject) reads.push({ section: this, subject: this.subject })
|
|
66
|
+
}
|
|
67
|
+
render() {
|
|
68
|
+
return html`<input />`
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
customElements.define('kx-section', KxSection)
|
|
72
|
+
registerEntity360Section('kx-lot', { name: 'facts', render: subject => html`<kx-section .subject=${subject}></kx-section>` })
|
|
73
|
+
const section = () => dock().shadowRoot!.querySelector('ox-entity360-content')!.shadowRoot!.querySelector('kx-section') as KxSection
|
|
41
74
|
|
|
42
75
|
describe('entity 360 by kind', () => {
|
|
43
76
|
beforeEach(async () => {
|
|
@@ -58,7 +91,7 @@ describe('entity 360 by kind', () => {
|
|
|
58
91
|
})
|
|
59
92
|
|
|
60
93
|
it('★ a click on the link opens that kind with that id', async () => {
|
|
61
|
-
const host =
|
|
94
|
+
const host = drawn(html`${entity360Link('kx-order', 'SO-2')}`)
|
|
62
95
|
;(host.querySelector('a') as HTMLElement).click()
|
|
63
96
|
await settle()
|
|
64
97
|
|
|
@@ -151,22 +184,123 @@ describe('entity 360 by kind', () => {
|
|
|
151
184
|
expect(address()).to.equal(null)
|
|
152
185
|
})
|
|
153
186
|
|
|
154
|
-
it('
|
|
155
|
-
|
|
187
|
+
it('★ what a section reads names the subject: the heading is drawn from it, the sections stay', async () => {
|
|
188
|
+
openEntity360Kind('kx-lot', { id: 'L-9', name: 'L-9' })
|
|
189
|
+
await settle()
|
|
190
|
+
expect(dock().view!.title).to.equal('L-9')
|
|
191
|
+
const first = section()
|
|
192
|
+
|
|
193
|
+
setEntity360Value({ kind: 'kx-lot', id: 'L-9' }, { name: 'L-9', definitionId: 'M-1' })
|
|
156
194
|
openEntity360Kind('kx-late', { id: 'X-9' })
|
|
157
195
|
await settle()
|
|
158
196
|
expect(dock().view!.title).to.equal('X-9')
|
|
159
|
-
|
|
160
|
-
late = 'named later'
|
|
161
|
-
redrawEntity360()
|
|
197
|
+
setEntity360Value({ kind: 'kx-late', id: 'X-9' }, { name: 'named later' })
|
|
162
198
|
await settle()
|
|
163
199
|
expect(dock().view!.title).to.equal('named later')
|
|
200
|
+
expect(first.isConnected).to.equal(false)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('★ a kind that declares its identifier: a typed value, a link and the address all open the same subject', async () => {
|
|
204
|
+
reads.length = 0
|
|
205
|
+
const host = drawn(html`${entity360Link('kx-lot', 'L-1')}`)
|
|
206
|
+
;(host.querySelector('a') as HTMLElement).click()
|
|
207
|
+
await settle()
|
|
208
|
+
expect(address()).to.equal('kx-lot:L-1')
|
|
209
|
+
dock().view!.onClose()
|
|
210
|
+
await settle()
|
|
211
|
+
|
|
212
|
+
openEntity360Kind('kx-lot', { __typename: 'KxLot', id: '0c9e-uuid', name: 'L-1' })
|
|
213
|
+
await settle()
|
|
214
|
+
expect(address()).to.equal('kx-lot:L-1')
|
|
215
|
+
expect(reads.map(read => read.subject.id)).to.deep.equal(['L-1', 'L-1'])
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('★ a shared link brings only the id - the sections still read by it', async () => {
|
|
219
|
+
reads.length = 0
|
|
220
|
+
const url = new URL(location.href)
|
|
221
|
+
url.searchParams.set('f', 'kx-lot:L-shared')
|
|
222
|
+
history.pushState(null, '', url)
|
|
223
|
+
window.dispatchEvent(new PopStateEvent('popstate', { state: null }))
|
|
224
|
+
await settle()
|
|
225
|
+
expect(dock().view!.title).to.equal('L-shared')
|
|
226
|
+
expect(reads[reads.length - 1]?.subject).to.deep.include({ kind: 'kx-lot', id: 'L-shared' })
|
|
227
|
+
expect(reads[reads.length - 1]?.subject.value).to.deep.equal({ id: 'L-shared' })
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('★ refresh gives the sections a new subject of the same id; they read again and are not made anew', async () => {
|
|
231
|
+
openEntity360Kind('kx-lot', { id: 'L-2', name: 'L-2' })
|
|
232
|
+
await settle()
|
|
233
|
+
const before = section()
|
|
234
|
+
const input = before.querySelector('input')!
|
|
235
|
+
input.value = 'typed'
|
|
236
|
+
reads.length = 0
|
|
237
|
+
dock().view!.onRefresh!()
|
|
238
|
+
await settle()
|
|
239
|
+
expect(section()).to.equal(before)
|
|
240
|
+
expect(input.value).to.equal('typed')
|
|
241
|
+
expect(reads.length).to.equal(1)
|
|
242
|
+
expect(reads[0].subject.id).to.equal('L-2')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('★ leaving a subject whose entry no draft keeps is asked about; keeping it stays put', async () => {
|
|
246
|
+
const open = OxPrompt.open
|
|
247
|
+
let asked = 0
|
|
248
|
+
let answer = false
|
|
249
|
+
;(OxPrompt as any).open = async () => (asked++, answer)
|
|
250
|
+
try {
|
|
251
|
+
openEntity360Kind('kx-lot', { id: 'L-3', name: 'L-3' })
|
|
252
|
+
await settle()
|
|
253
|
+
const input = section().querySelector('input')!
|
|
254
|
+
input.dispatchEvent(new CustomEvent(ENTITY360_UNKEPT, { bubbles: true, composed: true, detail: { unkept: true } }))
|
|
255
|
+
|
|
256
|
+
openEntity360Kind('kx-lot', { id: 'L-4', name: 'L-4' })
|
|
257
|
+
await settle()
|
|
258
|
+
expect(asked).to.equal(1)
|
|
259
|
+
expect(address()).to.equal('kx-lot:L-3')
|
|
260
|
+
expect(input.isConnected).to.equal(true)
|
|
261
|
+
|
|
262
|
+
answer = true
|
|
263
|
+
openEntity360Kind('kx-lot', { id: 'L-4', name: 'L-4' })
|
|
264
|
+
await settle()
|
|
265
|
+
expect(asked).to.equal(2)
|
|
266
|
+
expect(address()).to.equal('kx-lot:L-4')
|
|
267
|
+
} finally {
|
|
268
|
+
;(OxPrompt as any).open = open
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('★ opening the subject already shown does not hand the sections a new subject', async () => {
|
|
273
|
+
const url = new URL(location.href)
|
|
274
|
+
url.searchParams.set('f', 'kx-lot:L-7')
|
|
275
|
+
history.pushState(null, '', url)
|
|
276
|
+
window.dispatchEvent(new PopStateEvent('popstate', { state: null }))
|
|
277
|
+
await settle()
|
|
278
|
+
reads.length = 0
|
|
279
|
+
openEntity360Kind('kx-lot', { id: 'L-7', name: 'L-7' })
|
|
280
|
+
await settle()
|
|
281
|
+
expect(reads.length).to.equal(0)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('a kept entry - or none - is left without a question', async () => {
|
|
285
|
+
const open = OxPrompt.open
|
|
286
|
+
let asked = 0
|
|
287
|
+
;(OxPrompt as any).open = async () => (asked++, false)
|
|
288
|
+
try {
|
|
289
|
+
openEntity360Kind('kx-lot', { id: 'L-5', name: 'L-5' })
|
|
290
|
+
await settle()
|
|
291
|
+
openEntity360Kind('kx-lot', { id: 'L-6', name: 'L-6' })
|
|
292
|
+
await settle()
|
|
293
|
+
expect(asked).to.equal(0)
|
|
294
|
+
expect(address()).to.equal('kx-lot:L-6')
|
|
295
|
+
} finally {
|
|
296
|
+
;(OxPrompt as any).open = open
|
|
297
|
+
}
|
|
164
298
|
})
|
|
165
299
|
|
|
166
300
|
it('a link carries more for the sections, beside the id', async () => {
|
|
167
301
|
const heard: (Entity360Subject | undefined)[] = []
|
|
168
302
|
const stop = onEntity360Change(subject => heard.push(subject))
|
|
169
|
-
const host =
|
|
303
|
+
const host = drawn(html`${entity360Link('kx-order', 'SO-10', undefined, { instanceId: 'twin-2' })}`)
|
|
170
304
|
;(host.querySelector('a') as HTMLElement).click()
|
|
171
305
|
await settle()
|
|
172
306
|
stop()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect, fixture } from '@open-wc/testing'
|
|
2
2
|
import { html } from 'lit'
|
|
3
3
|
import '../src/index.js'
|
|
4
|
-
import { readEntity360Door, type OxEntity360Section } from '../src/index.js'
|
|
4
|
+
import { askEntity360Door, Entity360Reads, readEntity360Door, type OxEntity360Section } from '../src/index.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* A 360 section says what its door answered (ADR-0067): something, nothing, may-not-see, or failed.
|
|
@@ -11,6 +11,39 @@ import { readEntity360Door, type OxEntity360Section } from '../src/index.js'
|
|
|
11
11
|
const refused = { data: null, errors: [{ message: 'not allowed', extensions: { code: 'FORBIDDEN' } }] }
|
|
12
12
|
|
|
13
13
|
describe('entity 360 door', () => {
|
|
14
|
+
it('★ a request that throws is failed, with its own message - never left loading (ADR-0067 decision 7)', async () => {
|
|
15
|
+
const answer = await askEntity360Door(Promise.reject(new Error('Failed to fetch')), d => d?.items)
|
|
16
|
+
expect(answer).to.deep.equal({ state: 'failed', message: 'Failed to fetch' })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('★ a client that throws on a refusal still says forbidden', async () => {
|
|
20
|
+
const thrown = Object.assign(new Error('not allowed'), { graphQLErrors: refused.errors })
|
|
21
|
+
expect((await askEntity360Door(Promise.reject(thrown), d => d?.items)).state).to.equal('forbidden')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('★ only the newest read answers - a second read of the same id drops the first', async () => {
|
|
25
|
+
const reads = new Entity360Reads()
|
|
26
|
+
let release!: (value: any) => void
|
|
27
|
+
const first = reads.ask(() => new Promise(resolve => (release = resolve)), d => d.items)
|
|
28
|
+
const second = reads.ask(() => Promise.resolve({ data: { items: [2] } }), d => d.items)
|
|
29
|
+
release({ data: { items: [1] } })
|
|
30
|
+
expect(await first).to.equal(undefined)
|
|
31
|
+
expect((await second)?.data).to.deep.equal([2])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('a read that throws synchronously still ends failed; forget drops one on its way', async () => {
|
|
35
|
+
const reads = new Entity360Reads()
|
|
36
|
+
expect((await reads.ask(() => { throw new Error('no client') }, d => d))?.state).to.equal('failed')
|
|
37
|
+
const pending = reads.ask(() => Promise.resolve({ data: { items: [1] } }), d => d.items)
|
|
38
|
+
reads.forget()
|
|
39
|
+
expect(await pending).to.equal(undefined)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('a request that answers is read as readEntity360Door reads it', async () => {
|
|
43
|
+
expect((await askEntity360Door(Promise.resolve({ data: { items: [] } }), d => d.items)).state).to.equal('none')
|
|
44
|
+
expect((await askEntity360Door(Promise.resolve({ data: { items: [1] } }), d => d.items)).data).to.deep.equal([1])
|
|
45
|
+
})
|
|
46
|
+
|
|
14
47
|
it('★ a refusal is forbidden, not none - even when data came back empty beside it', () => {
|
|
15
48
|
expect(readEntity360Door(refused, d => d?.items).state).to.equal('forbidden')
|
|
16
49
|
expect(readEntity360Door({ ...refused, data: { items: [] } }, d => d.items).state).to.equal('forbidden')
|
package/test/entity360.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect, fixture, elementUpdated } from '@open-wc/testing'
|
|
2
2
|
import { html } from 'lit'
|
|
3
3
|
import '../src/index.js'
|
|
4
|
-
import { showEntity360, hideEntity360, navigateEntity360, canGoBackEntity360, type OxEntity360Dock, type Entity360View } from '../src/index.js'
|
|
4
|
+
import { showEntity360, hideEntity360, isEntity360Unkept, navigateEntity360, canGoBackEntity360, ENTITY360_UNKEPT, type OxEntity360Dock, type Entity360View } from '../src/index.js'
|
|
5
5
|
|
|
6
6
|
const owner = {}
|
|
7
7
|
const labels = { close: 'Close', expand: 'Expand', collapse: 'Collapse', resize: 'Resize', back: 'Back' }
|
|
@@ -38,20 +38,45 @@ describe('shared nonmodal entity 360 dock', () => {
|
|
|
38
38
|
expect(original.shadowRoot!.textContent).to.contain('Approval after · Revision 2')
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
-
it('
|
|
42
|
-
|
|
41
|
+
it('★ refresh reads again and keeps the contents - what is being written stays (ADR-0067 decision 8)', async () => {
|
|
42
|
+
let refreshed = 0
|
|
43
|
+
showEntity360(owner, { ...view(), onRefresh: () => refreshed++ })
|
|
43
44
|
await settle()
|
|
44
45
|
const original = dock()
|
|
45
46
|
original.expanded = true
|
|
46
47
|
const content = original.shadowRoot!.querySelector('ox-entity360-content') as HTMLElement
|
|
47
48
|
const input = content.shadowRoot!.querySelector('input')!
|
|
49
|
+
input.value = 'typed, not saved'
|
|
48
50
|
;(original.shadowRoot!.querySelector('[data-action="refresh"]') as HTMLElement).click()
|
|
49
51
|
await settle()
|
|
52
|
+
expect(refreshed).to.equal(1)
|
|
50
53
|
expect(dock()).to.equal(original)
|
|
51
54
|
expect(original.expanded).to.equal(true)
|
|
52
|
-
expect(
|
|
53
|
-
expect(
|
|
54
|
-
|
|
55
|
+
expect(content.shadowRoot!.querySelector('input')).to.equal(input)
|
|
56
|
+
expect(input.value).to.equal('typed, not saved')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('a dock that cannot read again draws no refresh button', async () => {
|
|
60
|
+
showEntity360(owner, view())
|
|
61
|
+
await settle()
|
|
62
|
+
expect(dock().shadowRoot!.querySelector('[data-action="refresh"]')).to.equal(null)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('★ a section holding an entry no draft keeps makes the dock unkept, until it says otherwise', async () => {
|
|
66
|
+
showEntity360(owner, view())
|
|
67
|
+
await settle()
|
|
68
|
+
const content = dock().shadowRoot!.querySelector('ox-entity360-content') as HTMLElement
|
|
69
|
+
const input = content.shadowRoot!.querySelector('input')!
|
|
70
|
+
const tell = (unkept: boolean) => input.dispatchEvent(new CustomEvent(ENTITY360_UNKEPT, { bubbles: true, composed: true, detail: { unkept } }))
|
|
71
|
+
expect(isEntity360Unkept(owner)).to.equal(false)
|
|
72
|
+
tell(true)
|
|
73
|
+
expect(isEntity360Unkept(owner)).to.equal(true)
|
|
74
|
+
tell(false)
|
|
75
|
+
expect(isEntity360Unkept(owner)).to.equal(false)
|
|
76
|
+
tell(true)
|
|
77
|
+
showEntity360(owner, view('Revision 2'))
|
|
78
|
+
await settle()
|
|
79
|
+
expect(isEntity360Unkept(owner)).to.equal(false)
|
|
55
80
|
})
|
|
56
81
|
|
|
57
82
|
it('uses compact header controls and the shared scrollbar theme without local overrides', async () => {
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { expect, fixture } from '@open-wc/testing'
|
|
2
|
+
import { html } from 'lit'
|
|
3
|
+
import '../src/index.js'
|
|
4
|
+
import '../src/components/ox-id-lens.js'
|
|
5
|
+
import {
|
|
6
|
+
askIdLensDoors,
|
|
7
|
+
foldIdLensAnswers,
|
|
8
|
+
idLensAnswerOf,
|
|
9
|
+
idLensHitToOpen,
|
|
10
|
+
lookupText,
|
|
11
|
+
registerEntity360Kind,
|
|
12
|
+
type IdLensDoor
|
|
13
|
+
} from '../src/index.js'
|
|
14
|
+
import type { OxIdLensResults } from '../src/components/ox-id-lens.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The ID Lens (ADR-0133): the products give the doors, the lens asks, folds and opens. These are the rules every
|
|
18
|
+
* product's lens shares, and the guard that keeps any product's words out of the shared code.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const a = { kind: 'idlens-test-a', id: 'X-1', name: 'X-1' }
|
|
22
|
+
const b = { kind: 'idlens-test-b', id: 'X-1', name: 'X-1', status: 'open' }
|
|
23
|
+
const registered = (kind: string) => kind === a.kind || kind === b.kind
|
|
24
|
+
|
|
25
|
+
const door = (kind: string, ask: IdLensDoor['ask'], label = kind.toUpperCase()): IdLensDoor => ({ kind, label: () => label, ask })
|
|
26
|
+
|
|
27
|
+
describe('ID Lens rules', () => {
|
|
28
|
+
it('only the ends are trimmed - case, prefixes and digits stay as typed', () => {
|
|
29
|
+
expect(lookupText(' wo-2609-041 \n')).to.equal('wo-2609-041')
|
|
30
|
+
expect(lookupText('(01)08801234567890(10)A1')).to.equal('(01)08801234567890(10)A1')
|
|
31
|
+
expect(lookupText(undefined)).to.equal('')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('one hit of a registered kind, every door answered - its 360 opens', () => {
|
|
35
|
+
const outcome = foldIdLensAnswers('X-1', [
|
|
36
|
+
{ kind: a.kind, state: 'ready', hit: a },
|
|
37
|
+
{ kind: b.kind, state: 'none' }
|
|
38
|
+
])
|
|
39
|
+
expect(idLensHitToOpen(outcome, registered)).to.deep.equal(a)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('the same identifier in two kinds is a choice for the person - the first is never picked', () => {
|
|
43
|
+
const outcome = foldIdLensAnswers('X-1', [
|
|
44
|
+
{ kind: a.kind, state: 'ready', hit: a },
|
|
45
|
+
{ kind: b.kind, state: 'ready', hit: b }
|
|
46
|
+
])
|
|
47
|
+
expect(outcome.hits).to.have.length(2)
|
|
48
|
+
expect(idLensHitToOpen(outcome, registered)).to.equal(undefined)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('a door that failed might have held another hit - the one hit is shown, not opened', () => {
|
|
52
|
+
const outcome = foldIdLensAnswers('X-1', [
|
|
53
|
+
{ kind: a.kind, state: 'ready', hit: a },
|
|
54
|
+
{ kind: b.kind, state: 'failed', message: 'timeout' }
|
|
55
|
+
])
|
|
56
|
+
expect(idLensHitToOpen(outcome, registered)).to.equal(undefined)
|
|
57
|
+
expect(outcome.failed).to.deep.equal([{ kind: b.kind, message: 'timeout' }])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('a kind this person may not read is named by kind only and does not stop the open', () => {
|
|
61
|
+
const outcome = foldIdLensAnswers('X-1', [
|
|
62
|
+
{ kind: a.kind, state: 'forbidden' },
|
|
63
|
+
{ kind: b.kind, state: 'ready', hit: b }
|
|
64
|
+
])
|
|
65
|
+
expect(outcome.forbidden).to.deep.equal([a.kind])
|
|
66
|
+
expect(idLensHitToOpen(outcome, registered)).to.deep.equal(b)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('a hit whose kind has no 360 is listed, never opened', () => {
|
|
70
|
+
const outcome = foldIdLensAnswers('X-1', [{ kind: 'idlens-test-none', state: 'ready', hit: { kind: 'idlens-test-none', id: 'X-1', name: 'X-1' } }])
|
|
71
|
+
expect(idLensHitToOpen(outcome, registered)).to.equal(undefined)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('a door that threw did not answer - it is failed, not none, and the other doors still count', async () => {
|
|
75
|
+
const outcome = await askIdLensDoors(
|
|
76
|
+
[
|
|
77
|
+
door(a.kind, async () => ({ state: 'ready', hit: a })),
|
|
78
|
+
door(b.kind, async () => {
|
|
79
|
+
throw new Error('offline')
|
|
80
|
+
})
|
|
81
|
+
],
|
|
82
|
+
'X-1'
|
|
83
|
+
)
|
|
84
|
+
expect(outcome.hits).to.deep.equal([a])
|
|
85
|
+
expect(outcome.failed).to.deep.equal([{ kind: b.kind, message: 'offline' }])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("a door's response reads as the 360 doors do: refused is forbidden, nothing is none", () => {
|
|
89
|
+
const hitOf = (row: { name: string }) => ({ kind: a.kind, id: row.name, name: row.name })
|
|
90
|
+
expect(idLensAnswerOf({ data: { row: { name: 'X-1' } } }, data => data.row, hitOf)).to.deep.equal({ state: 'ready', hit: a })
|
|
91
|
+
expect(idLensAnswerOf({ data: { row: null } }, data => data.row, hitOf).state).to.equal('none')
|
|
92
|
+
expect(idLensAnswerOf({ errors: [{ extensions: { code: 'FORBIDDEN' } }] }, data => data.row, hitOf).state).to.equal('forbidden')
|
|
93
|
+
/* A product that finds the row but does not count it as a hit says so with undefined. */
|
|
94
|
+
expect(idLensAnswerOf({ data: { row: { name: 'X-1' } } }, data => data.row, () => undefined).state).to.equal('none')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('ID Lens results', () => {
|
|
99
|
+
before(() => registerEntity360Kind({ kind: a.kind, typenames: [], title: subject => subject.id }))
|
|
100
|
+
|
|
101
|
+
it('kinds are said in the words the product gave its doors', async () => {
|
|
102
|
+
const doors = [door(a.kind, async () => ({ state: 'none' }), 'Kind A'), door(b.kind, async () => ({ state: 'none' }), 'Kind B')]
|
|
103
|
+
const outcome = foldIdLensAnswers('X-1', [
|
|
104
|
+
{ kind: a.kind, state: 'forbidden' },
|
|
105
|
+
{ kind: b.kind, state: 'failed' }
|
|
106
|
+
])
|
|
107
|
+
const results = await fixture<OxIdLensResults>(html`<ox-id-lens-results .outcome=${outcome} .doors=${doors}></ox-id-lens-results>`)
|
|
108
|
+
const text = results.shadowRoot!.textContent!
|
|
109
|
+
expect(text).to.contain('Kind A')
|
|
110
|
+
expect(text).to.contain('Kind B')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('a hit with a 360 or a work screen is a button; one with neither stays text and says so', async () => {
|
|
114
|
+
let opened = ''
|
|
115
|
+
const screen = { kind: 'idlens-test-screen', id: 'S-1', name: 'S-1', open: () => (opened = 'S-1') }
|
|
116
|
+
const bare = { kind: 'idlens-test-bare', id: 'B-1', name: 'B-1' }
|
|
117
|
+
const outcome = foldIdLensAnswers('X', [
|
|
118
|
+
{ kind: a.kind, state: 'ready', hit: a },
|
|
119
|
+
{ kind: screen.kind, state: 'ready', hit: screen },
|
|
120
|
+
{ kind: bare.kind, state: 'ready', hit: bare }
|
|
121
|
+
])
|
|
122
|
+
const picked: string[] = []
|
|
123
|
+
const results = await fixture<OxIdLensResults>(
|
|
124
|
+
html`<ox-id-lens-results .outcome=${outcome} .onPick=${(hit: any) => (picked.push(hit.id), hit.open?.())}></ox-id-lens-results>`
|
|
125
|
+
)
|
|
126
|
+
const buttons = Array.from(results.shadowRoot!.querySelectorAll('button.hit')) as HTMLButtonElement[]
|
|
127
|
+
expect(buttons).to.have.length(2)
|
|
128
|
+
buttons.forEach(button => button.click())
|
|
129
|
+
expect(picked).to.deep.equal(['X-1', 'S-1'])
|
|
130
|
+
expect(opened).to.equal('S-1')
|
|
131
|
+
expect(results.shadowRoot!.querySelector('div.hit')!.textContent).to.contain('B-1')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('ID Lens field', () => {
|
|
136
|
+
it('a placeholder given as a function is read at render - a band registered once follows the language', async () => {
|
|
137
|
+
let word = 'first'
|
|
138
|
+
const lens = await fixture<HTMLElement & { requestUpdate(): void; updateComplete: Promise<boolean> }>(
|
|
139
|
+
html`<ox-id-lens .placeholder=${() => word}></ox-id-lens>`
|
|
140
|
+
)
|
|
141
|
+
const field = () => lens.shadowRoot!.querySelector('ox-input-barcode')!.getAttribute('placeholder')
|
|
142
|
+
expect(field()).to.equal('first')
|
|
143
|
+
word = 'second'
|
|
144
|
+
lens.requestUpdate()
|
|
145
|
+
await lens.updateComplete
|
|
146
|
+
expect(field()).to.equal('second')
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('ID Lens element source', () => {
|
|
151
|
+
/* What the lens does is read from its source, as it was in operato-plant before the lens moved here. */
|
|
152
|
+
const lensSource = async () => (await (await fetch('/src/components/ox-id-lens.ts')).text()).replace(/\/\*[\s\S]*?\*\//g, '')
|
|
153
|
+
|
|
154
|
+
it('asks the 360 registry what opens - the doors say nothing about 360s', async () => {
|
|
155
|
+
const lens = await lensSource()
|
|
156
|
+
expect(lens).to.match(/idLensHitToOpen\(outcome, entity360KindRegistered\)/)
|
|
157
|
+
expect(lens).to.match(/openEntity360Kind\(/)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('reuses ox-input-barcode and listens to no page-wide keys; a page scanner is not the lens', async () => {
|
|
161
|
+
const lens = await lensSource()
|
|
162
|
+
expect(lens).to.match(/<ox-input-barcode /)
|
|
163
|
+
expect(lens).not.to.match(/window\.addEventListener|document\.addEventListener|key === 'Enter'|@keydown/)
|
|
164
|
+
for (const scanRule of ['listenForScans', 'classifyScan', 'parseGs1']) expect(lens.includes(scanRule), scanRule).to.equal(false)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('leaving the field is not a request to look - only Enter or a scan, which keep focus in the lens', async () => {
|
|
168
|
+
const lens = await lensSource()
|
|
169
|
+
expect(lens).to.match(/@change=\$\{this\.committed\}/)
|
|
170
|
+
expect(lens).to.match(/if \(this\.matches\(':focus-within'\)\) this\.find\(\)/)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('the front mark follows the camera: with one, the magnifier finds; without, the QR mark', async () => {
|
|
174
|
+
const lens = await lensSource()
|
|
175
|
+
expect(lens).to.match(/class="find"[^>]*@click=\$\{\(\) => this\.find\(\)\}>\s*<md-icon aria-hidden="true">search<\/md-icon>/)
|
|
176
|
+
expect(lens).to.match(/class="scan"[^>]*@click=\$\{\(\) => this\.field\?\.input\?\.focus\(\)\}>\s*<md-icon aria-hidden="true">qr_code_scanner<\/md-icon>/)
|
|
177
|
+
expect(lens).to.match(/\.lens:has\(> ox-input-barcode\[scannable\]\) \.scan,\s*\.lens:not\(:has\(> ox-input-barcode\[scannable\]\)\) \.find \{\s*display: none;/)
|
|
178
|
+
expect(lens).not.to.match(/without-scan-button/)
|
|
179
|
+
expect(lens).to.match(/--barcodescan-input-button-icon-size: auto 15px;/)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('ID Lens holds no product', () => {
|
|
184
|
+
/*
|
|
185
|
+
* ADR-0133 2-1: the shared lens owns no kind's name, rule or privilege. A product word here means a product's
|
|
186
|
+
* knowledge moved into the shared code - the doors are where it belongs (architect ruling 2026-10-23).
|
|
187
|
+
*/
|
|
188
|
+
const words = /\b(plant|warehouse|lots?|job-?orders?|sscc|sgln|gtin|locations?|pallets?|orders?|tasks?)\b|로트|작업지시|창고|공장/i
|
|
189
|
+
|
|
190
|
+
for (const file of ['/src/actions/id-lens.ts', '/src/components/ox-id-lens.ts']) {
|
|
191
|
+
it(`${file} names no product's kinds`, async () => {
|
|
192
|
+
const source = await (await fetch(file)).text()
|
|
193
|
+
expect(source.length, `${file} was not read`).to.be.greaterThan(1000)
|
|
194
|
+
const found = source.match(words)
|
|
195
|
+
expect(found?.[0], `${file} says "${found?.[0]}"`).to.equal(undefined)
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
})
|