@things-factory/apptool-ui 10.1.27 → 10.1.28
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-client/index.d.ts +17 -2
- package/dist-client/index.js +31 -17
- package/dist-client/index.js.map +1 -1
- package/dist-client/layout/app-header-band.d.ts +54 -0
- package/dist-client/layout/app-header-band.js +287 -0
- package/dist-client/layout/app-header-band.js.map +1 -0
- package/dist-client/layout/header-band-model.d.ts +86 -0
- package/dist-client/layout/header-band-model.js +93 -0
- package/dist-client/layout/header-band-model.js.map +1 -0
- package/dist-client/layout/header-scope.d.ts +12 -0
- package/dist-client/layout/header-scope.js +33 -0
- package/dist-client/layout/header-scope.js.map +1 -0
- package/dist-client/mdibar-setting-let.d.ts +3 -1
- package/dist-client/mdibar-setting-let.js +17 -3
- package/dist-client/mdibar-setting-let.js.map +1 -1
- package/dist-client/mdibar-setting-state.d.ts +29 -0
- package/dist-client/mdibar-setting-state.js +35 -0
- package/dist-client/mdibar-setting-state.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/tests/header-band.test.ts +182 -0
- package/tests/mdibar-setting-state.test.ts +78 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { readFileSync } from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
menuButtonShown,
|
|
6
|
+
scopeName,
|
|
7
|
+
scopeShape,
|
|
8
|
+
SLOT_ORDER,
|
|
9
|
+
toolsBySlot,
|
|
10
|
+
unreadLabel,
|
|
11
|
+
wordmark
|
|
12
|
+
} from '../client/layout/header-band-model'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The shared header band (ADR-0060 decision 4 and its 2026-09-18 addendum).
|
|
16
|
+
*
|
|
17
|
+
* The drawing needs a DOM and this repository's runner has none, so what a test can reach is the
|
|
18
|
+
* deciding: which slot a tool belongs to, whether the menu button is drawn, what shape a scope
|
|
19
|
+
* takes. The rest is held by reading the band's own source - the rules that matter there are about
|
|
20
|
+
* what the band must not do (keep a second registry, know an app's words), and those are visible in
|
|
21
|
+
* the text.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const BAND = path.resolve(__dirname, '../client/layout/app-header-band.ts')
|
|
25
|
+
const bandSource = readFileSync(BAND, 'utf-8')
|
|
26
|
+
|
|
27
|
+
/** The source with its comments stripped, so prose about a rule cannot satisfy the rule. */
|
|
28
|
+
const bandCode = bandSource.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
|
29
|
+
|
|
30
|
+
describe('the band splits one registry', () => {
|
|
31
|
+
it('★ the five slot names are still the ones @operato/layout declares', () => {
|
|
32
|
+
/*
|
|
33
|
+
* The model writes the five out rather than importing the enum: @operato/layout is an ES module
|
|
34
|
+
* and this runner does not transform node_modules, so an import there would put every decision
|
|
35
|
+
* out of reach of every test. This reads the installed declaration instead, so the two cannot
|
|
36
|
+
* drift apart in silence.
|
|
37
|
+
*/
|
|
38
|
+
const declaration = readFileSync(
|
|
39
|
+
path.resolve(__dirname, '../../../node_modules/@operato/layout/dist/src/actions/layout.d.ts'),
|
|
40
|
+
'utf-8'
|
|
41
|
+
)
|
|
42
|
+
const body = declaration.slice(declaration.indexOf('enum TOOL_POSITION'))
|
|
43
|
+
const declared = [...body.slice(0, body.indexOf('}')).matchAll(/(\w+) = "(\w+)"/g)].map(match => match[2])
|
|
44
|
+
|
|
45
|
+
expect(declared).toEqual([...SLOT_ORDER])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('★ every tool lands in the slot it declared', () => {
|
|
49
|
+
const tools = [
|
|
50
|
+
{ name: 'hamburger', position: 'FRONT_END' },
|
|
51
|
+
{ name: 'favorite', position: 'REAR' },
|
|
52
|
+
{ name: 'more', position: 'REAR_END' },
|
|
53
|
+
{ name: 'title-bar', position: 'CENTER' },
|
|
54
|
+
{ name: 'unplaced' }
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
const bySlot = toolsBySlot(tools)
|
|
58
|
+
|
|
59
|
+
expect(SLOT_ORDER.map(slot => bySlot[slot].map(tool => tool.name))).toEqual([
|
|
60
|
+
['hamburger'],
|
|
61
|
+
[],
|
|
62
|
+
['title-bar'],
|
|
63
|
+
['favorite'],
|
|
64
|
+
['more']
|
|
65
|
+
])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('★ and it reads the registry the app toolbar reads, rather than keeping one of its own', () => {
|
|
69
|
+
/*
|
|
70
|
+
* A second registry would make every contributor pick a side: lite-menu's hamburger and
|
|
71
|
+
* more-ui's morenda button are dispatched once, to one place, by packages that do not know
|
|
72
|
+
* which of the two is drawing.
|
|
73
|
+
*/
|
|
74
|
+
expect(bandCode).toContain('store.getState()')
|
|
75
|
+
expect(bandCode).toContain('apptool')
|
|
76
|
+
expect(bandCode).not.toContain('APPEND_APP_TOOL')
|
|
77
|
+
expect(bandCode).not.toContain('store.dispatch')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('★ and it draws the five slots left to right in the declared order', () => {
|
|
81
|
+
const order = ['frontEnd', 'front', 'center', 'rear', 'rearEnd']
|
|
82
|
+
const template = bandCode.slice(bandCode.indexOf('<div class="band">'))
|
|
83
|
+
const drawn = order
|
|
84
|
+
.map(name => ({ name, at: template.indexOf(`${name}.map(`) }))
|
|
85
|
+
.filter(({ at }) => at >= 0)
|
|
86
|
+
|
|
87
|
+
expect(drawn.map(({ name }) => name)).toEqual(order)
|
|
88
|
+
expect(drawn.map(({ at }) => at)).toEqual([...drawn.map(({ at }) => at)].sort((a, b) => a - b))
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('the way into the menu', () => {
|
|
93
|
+
const hamburger = [{ name: 'hamburger', position: 'FRONT_END' }]
|
|
94
|
+
|
|
95
|
+
it('★ the band draws a menu button while the rail is hidden', () => {
|
|
96
|
+
/*
|
|
97
|
+
* lite-menu hides the rail on a narrow viewport and registers its hamburger in the app toolbar,
|
|
98
|
+
* which a band app has turned off. twin had no button of its own, so at 375px there was no way
|
|
99
|
+
* to leave the screen you were on (2026-09-18).
|
|
100
|
+
*/
|
|
101
|
+
expect(menuButtonShown(false, [])).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('and not while the rail is on screen, or before the menu part has registered', () => {
|
|
105
|
+
expect(menuButtonShown(true, [])).toBe(false)
|
|
106
|
+
/* Absent reads as shown, so the button does not flash on during boot. */
|
|
107
|
+
expect(menuButtonShown(undefined, [])).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('★ and not when something already registered one, so there are never two', () => {
|
|
111
|
+
expect(menuButtonShown(false, hamburger)).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('the notification count', () => {
|
|
116
|
+
it('★ nothing is drawn for nothing unread', () => {
|
|
117
|
+
expect(unreadLabel(0)).toBeUndefined()
|
|
118
|
+
expect(unreadLabel(undefined)).toBeUndefined()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('and the exact number stops being worth the width past 99', () => {
|
|
122
|
+
expect(unreadLabel(1)).toEqual('1')
|
|
123
|
+
expect(unreadLabel(99)).toEqual('99')
|
|
124
|
+
expect(unreadLabel(100)).toEqual('99+')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('★ the bell opens the side panel, rather than navigating to a page', () => {
|
|
128
|
+
/*
|
|
129
|
+
* plant sent this to `notification-list-page` for a while. An alarm is glanced at without
|
|
130
|
+
* leaving the screen you are on, and navigating away is the thing that was wrong with it.
|
|
131
|
+
*/
|
|
132
|
+
expect(bandCode).toContain("toggleOverlay('notification'")
|
|
133
|
+
expect(bandCode).not.toContain('notification-list-page')
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('the scope the app hands over', () => {
|
|
138
|
+
it('★ one of something draws its name, with nothing to press', () => {
|
|
139
|
+
const scope = { label: 'factory', items: [{ id: 'a', label: '미라텍' }], selectedId: 'a' }
|
|
140
|
+
|
|
141
|
+
expect(scopeShape(scope)).toEqual('name')
|
|
142
|
+
expect(scopeName(scope)).toEqual('미라텍')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('and several draw a picker, and none draws nothing', () => {
|
|
146
|
+
const two = {
|
|
147
|
+
label: 'factory',
|
|
148
|
+
items: [
|
|
149
|
+
{ id: 'a', label: '미라텍' },
|
|
150
|
+
{ id: 'b', label: '제2공장' }
|
|
151
|
+
],
|
|
152
|
+
selectedId: 'a'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
expect(scopeShape(two)).toEqual('picker')
|
|
156
|
+
expect(scopeShape(undefined)).toEqual('none')
|
|
157
|
+
expect(scopeShape({ label: 'factory', items: [] })).toEqual('none')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('★ and the band never spells a scope word of its own', () => {
|
|
161
|
+
/*
|
|
162
|
+
* ADR-0048 · 0058: the shared band does not know the app's vocabulary. plant's scope is a
|
|
163
|
+
* factory, twin's is a site, warehouse's is a warehouse, and each of those words belongs to the
|
|
164
|
+
* app that passes it.
|
|
165
|
+
*/
|
|
166
|
+
const theirs = ['factory', 'site', 'warehouse', '공장', '현장', '창고', 'domain', 'subdomain']
|
|
167
|
+
|
|
168
|
+
expect(theirs.filter(word => bandCode.toLowerCase().includes(word.toLowerCase()))).toEqual([])
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe('the product name', () => {
|
|
173
|
+
it('★ the band owns the shape and the app gives one word', () => {
|
|
174
|
+
expect(wordmark('plant')).toEqual({ lead: 'operato', name: 'plant' })
|
|
175
|
+
expect(wordmark(' figure ')).toEqual({ lead: 'operato', name: 'figure' })
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('and an app that gives no word gets no wordmark', () => {
|
|
179
|
+
expect(wordmark(undefined)).toBeUndefined()
|
|
180
|
+
expect(wordmark('')).toBeUndefined()
|
|
181
|
+
})
|
|
182
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync } from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
|
|
4
|
+
import { mdibarShownNow, resolveMdibarShow } from '../client/mdibar-setting-state'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The MDI setting row must say what the tab bar is doing.
|
|
8
|
+
*
|
|
9
|
+
* It read the stored setting alone, so in an app that starts the bar on
|
|
10
|
+
* (`setupAppToolPart({ mdibar: { default: true } })`) the row drew an empty checkbox over a bar that
|
|
11
|
+
* was on screen. Seen on operato-plant at :4000, 2026-09-18.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
describe('what the MDI setting row shows', () => {
|
|
15
|
+
const stateWith = (show: boolean | undefined) => ({ layout: { viewparts: { appmdibar: { show } } } })
|
|
16
|
+
|
|
17
|
+
it('★ on, when the app starts the bar on and the user has stored nothing', () => {
|
|
18
|
+
/* setupAppToolPart folded `{ default: true }` into the viewpart; nothing is in clientSettingStore. */
|
|
19
|
+
expect(mdibarShownNow(stateWith(true))).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('off, when the user turned the bar off', () => {
|
|
23
|
+
expect(mdibarShownNow(stateWith(false))).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('off, when there is no bar to speak of', () => {
|
|
27
|
+
expect(mdibarShownNow({ layout: { viewparts: {} } })).toBe(false)
|
|
28
|
+
expect(mdibarShownNow({ layout: {} })).toBe(false)
|
|
29
|
+
expect(mdibarShownNow({})).toBe(false)
|
|
30
|
+
expect(mdibarShownNow(undefined)).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('a half-written viewpart is not "on" — only an explicit true is', () => {
|
|
34
|
+
expect(mdibarShownNow(stateWith(undefined))).toBe(false)
|
|
35
|
+
expect(mdibarShownNow({ layout: { viewparts: { appmdibar: { show: 'yes' } } } })).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('★ follows the bar while the settings card is on screen — a card that read once drew the old answer', () => {
|
|
39
|
+
const code = readFileSync(path.resolve(__dirname, '../client/mdibar-setting-let.ts'), 'utf-8')
|
|
40
|
+
|
|
41
|
+
expect(code).toMatch(/connectedCallback\(\)[\s\S]*store\.subscribe\(/)
|
|
42
|
+
expect(code).toMatch(/disconnectedCallback\(\)[\s\S]*this\._unsubscribe\?\.\(\)/)
|
|
43
|
+
expect(code).not.toMatch(/firstUpdated/)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('the row reads the bar, not the stored setting', () => {
|
|
47
|
+
const code = readFileSync(path.resolve(__dirname, '../client/mdibar-setting-let.ts'), 'utf-8')
|
|
48
|
+
|
|
49
|
+
const body = code.match(/connectedCallback\(\) \{([\s\S]*?)\n \}/)?.[1]
|
|
50
|
+
|
|
51
|
+
expect(body).toMatch(/this\.show = mdibarShownNow\(store\.getState\(\)\)/)
|
|
52
|
+
/* The stored value is the user's own choice; the viewpart already carries it, folded with the app's default. */
|
|
53
|
+
expect(body).not.toMatch(/clientSettingStore/)
|
|
54
|
+
/* Writing the default into the store would freeze it — the app could never change its own default again. */
|
|
55
|
+
expect(code).not.toMatch(/clientSettingStore\.put[\s\S]{0,120}default/)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('the state the bar starts in', () => {
|
|
59
|
+
it("★ the app's default holds until the user picks — one rule, shared with setupAppToolPart", () => {
|
|
60
|
+
expect(resolveMdibarShow(undefined, { default: true })).toBe(true)
|
|
61
|
+
expect(resolveMdibarShow(undefined, { default: false })).toBe(false)
|
|
62
|
+
expect(resolveMdibarShow(undefined, true)).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('the stored choice wins once it exists', () => {
|
|
66
|
+
expect(resolveMdibarShow(false, { default: true })).toBe(false)
|
|
67
|
+
expect(resolveMdibarShow(true, true)).toBe(true)
|
|
68
|
+
expect(resolveMdibarShow(true, { default: false })).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('setupAppToolPart uses that one rule instead of writing its own', () => {
|
|
72
|
+
const code = readFileSync(path.resolve(__dirname, '../client/index.ts'), 'utf-8')
|
|
73
|
+
|
|
74
|
+
expect(code).toMatch(/const show = resolveMdibarShow\(stored, mdibar\)/)
|
|
75
|
+
expect(code).not.toMatch(/stored === undefined \? fallback : stored/)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
})
|