@things-factory/apptool-ui 10.1.27 → 10.1.29

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.
@@ -0,0 +1,207 @@
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 puts the rail away on a mobile device in portrait, or when the app asks for
98
+ * `hovering`, and registers its hamburger in the app toolbar - which a band app has turned off.
99
+ * twin had no button of its own, so on a phone there was no way to leave the screen you were on
100
+ * (2026-09-18, seen under mobile emulation).
101
+ *
102
+ * A narrowed desktop window is not that case: the rail stays and spreads to the window's width.
103
+ * Warehouse looked for it at 375px on a desktop and it did not happen, then saw it under a
104
+ * Pixel 8 user agent (:3500, 2026-09-18) - the device check runs once at boot, so emulation
105
+ * only counts after a reload.
106
+ */
107
+ expect(menuButtonShown(false, [])).toBe(true)
108
+ })
109
+
110
+ it('and not while the rail is on screen, or before the menu part has registered', () => {
111
+ expect(menuButtonShown(true, [])).toBe(false)
112
+ /* Absent reads as shown, so the button does not flash on during boot. */
113
+ expect(menuButtonShown(undefined, [])).toBe(false)
114
+ })
115
+
116
+ it('★ and not when something already registered one, so there are never two', () => {
117
+ expect(menuButtonShown(false, hamburger)).toBe(false)
118
+ })
119
+ })
120
+
121
+ describe('the notification count', () => {
122
+ it('★ nothing is drawn for nothing unread', () => {
123
+ expect(unreadLabel(0)).toBeUndefined()
124
+ expect(unreadLabel(undefined)).toBeUndefined()
125
+ })
126
+
127
+ it('and the exact number stops being worth the width past 99', () => {
128
+ expect(unreadLabel(1)).toEqual('1')
129
+ expect(unreadLabel(99)).toEqual('99')
130
+ expect(unreadLabel(100)).toEqual('99+')
131
+ })
132
+
133
+ it('★ the bell opens the side panel, rather than navigating to a page', () => {
134
+ /*
135
+ * plant sent this to `notification-list-page` for a while. An alarm is glanced at without
136
+ * leaving the screen you are on, and navigating away is the thing that was wrong with it.
137
+ */
138
+ expect(bandCode).toContain("toggleOverlay('notification'")
139
+ expect(bandCode).not.toContain('notification-list-page')
140
+ })
141
+ })
142
+
143
+ describe('the scope the app hands over', () => {
144
+ it('★ one of something draws its name, with nothing to press', () => {
145
+ const scope = { label: 'factory', items: [{ id: 'a', label: '미라텍' }], selectedId: 'a' }
146
+
147
+ expect(scopeShape(scope)).toEqual('name')
148
+ expect(scopeName(scope)).toEqual('미라텍')
149
+ })
150
+
151
+ it('and several draw a picker, and none draws nothing', () => {
152
+ const two = {
153
+ label: 'factory',
154
+ items: [
155
+ { id: 'a', label: '미라텍' },
156
+ { id: 'b', label: '제2공장' }
157
+ ],
158
+ selectedId: 'a'
159
+ }
160
+
161
+ expect(scopeShape(two)).toEqual('picker')
162
+ expect(scopeShape(undefined)).toEqual('none')
163
+ expect(scopeShape({ label: 'factory', items: [] })).toEqual('none')
164
+ })
165
+
166
+ it('★ and the band never spells a scope word of its own', () => {
167
+ /*
168
+ * ADR-0048 · 0058: the shared band does not know the app's vocabulary. plant's scope is a
169
+ * factory, twin's is a site, warehouse's is a warehouse, and each of those words belongs to the
170
+ * app that passes it.
171
+ */
172
+ const theirs = ['factory', 'site', 'warehouse', '공장', '현장', '창고', 'domain', 'subdomain']
173
+
174
+ expect(theirs.filter(word => bandCode.toLowerCase().includes(word.toLowerCase()))).toEqual([])
175
+ })
176
+ })
177
+
178
+ describe('a tool from the registry', () => {
179
+ it('★ takes the band control shape rather than the toolbar one', () => {
180
+ /*
181
+ * lite-menu's hamburger and more-ui's morenda button are a bare md-icon apiece, written for the
182
+ * coloured app toolbar. On warehouse they came out as 18x18 marks with `cursor: auto` beside
183
+ * this row's 28px pill buttons (:3500, 2026-09-18) - nothing said they could be pressed.
184
+ *
185
+ * The rule reaches a tool because its template renders inside the band's own shadow root. The
186
+ * band's buttons keep their icons inside a `.ctl`, so the direct-child selector leaves them be.
187
+ */
188
+ const styles = bandSource.slice(bandSource.indexOf('static styles = ['), bandSource.indexOf('@property'))
189
+
190
+ expect(styles).toContain('.slot > md-icon')
191
+ expect(styles).toContain('.slot > button:not(.ctl)')
192
+ /* Pressable, and the same box the band's own buttons have. */
193
+ expect(styles.slice(styles.indexOf('.slot > md-icon'))).toContain('cursor: pointer')
194
+ })
195
+ })
196
+
197
+ describe('the product name', () => {
198
+ it('★ the band owns the shape and the app gives one word', () => {
199
+ expect(wordmark('plant')).toEqual({ lead: 'operato', name: 'plant' })
200
+ expect(wordmark(' figure ')).toEqual({ lead: 'operato', name: 'figure' })
201
+ })
202
+
203
+ it('and an app that gives no word gets no wordmark', () => {
204
+ expect(wordmark(undefined)).toBeUndefined()
205
+ expect(wordmark('')).toBeUndefined()
206
+ })
207
+ })
@@ -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
+ })
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "label.home": "home",
3
- "title.mdibar setting": "MDI setting",
4
- "text.confirm_close_all_tabs": "Are you sure you want to close all tabs?"
3
+ "label.show": "show",
4
+ "text.confirm_close_all_tabs": "Are you sure you want to close all tabs?",
5
+ "title.mdibar setting": "MDI setting"
5
6
  }
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "label.home": "ホーム",
3
- "title.mdibar setting": "MDI設定",
4
- "text.confirm_close_all_tabs": "開いているすべてのタブを閉じますか?"
3
+ "label.show": "表示",
4
+ "text.confirm_close_all_tabs": "開いているすべてのタブを閉じますか?",
5
+ "title.mdibar setting": "MDI設定"
5
6
  }
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "label.home": "홈",
3
- "title.mdibar setting": "MDI 설정",
4
- "text.confirm_close_all_tabs": "열려있는 모든 탭을 닫으시겠습니까?"
3
+ "label.show": "보이기",
4
+ "text.confirm_close_all_tabs": "열려있는 모든 탭을 닫으시겠습니까?",
5
+ "title.mdibar setting": "MDI 설정"
5
6
  }
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "label.home": "Rumah",
3
- "title.mdibar setting": "Tetapan MDI",
4
- "text.confirm_close_all_tabs": "Adakah anda pasti mahu menutup semua tab?"
3
+ "label.show": "tunjuk",
4
+ "text.confirm_close_all_tabs": "Adakah anda pasti mahu menutup semua tab?",
5
+ "title.mdibar setting": "Tetapan MDI"
5
6
  }
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "label.home": "首页",
3
- "title.mdibar setting": "MDI设置",
4
- "text.confirm_close_all_tabs": "确定要关闭所有标签页吗?"
3
+ "label.show": "显示",
4
+ "text.confirm_close_all_tabs": "确定要关闭所有标签页吗?",
5
+ "title.mdibar setting": "MDI设置"
5
6
  }