@things-factory/worklist 10.1.31 → 10.1.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/worklist",
3
- "version": "10.1.31",
3
+ "version": "10.1.32",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -41,10 +41,10 @@
41
41
  "@things-factory/auth-base": "^10.1.31",
42
42
  "@things-factory/board-service": "^10.1.31",
43
43
  "@things-factory/context-ui": "^10.1.31",
44
- "@things-factory/organization": "^10.1.31",
44
+ "@things-factory/organization": "^10.1.32",
45
45
  "@things-factory/personalization": "^10.1.31",
46
46
  "@things-factory/scheduler-client": "^10.1.31",
47
47
  "@things-factory/shell": "^10.1.31"
48
48
  },
49
- "gitHead": "5213e6f9913b40ea2618a2111d9884b792dbaa01"
49
+ "gitHead": "67b211e001da3a923e562cb048e4691bd961315c"
50
50
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A screen this module routes to is a screen the shell can actually build.
3
+ *
4
+ * ── How this fails, and why nothing says so ───────────────────────────────
5
+ * Three things have to agree for a page to open: `client/route.ts` maps the page name to an import,
6
+ * the element registers itself as a custom element, and `things-factory.config.js` carries a
7
+ * `{ tagname, page }` pair. The shell builds the element from **that pair**. With the first two and
8
+ * not the third, the route resolves, the element is defined, and the shell puts nothing in the DOM:
9
+ * a blank screen, no console error, nothing in the server log.
10
+ *
11
+ * operato-plant met this twice (2026-09-03 and 2026-09-15; its own config carries a comment saying
12
+ * so), and the work-door screens met it a third time on the way into this module - the page and its
13
+ * route came across and the pair did not. Warehouse found it by opening the screen.
14
+ *
15
+ * So the agreement is counted here rather than trusted: the config file is data and route.ts names
16
+ * its cases in plain text, and nobody has to remember the third place.
17
+ */
18
+
19
+ import { readFileSync } from 'fs'
20
+ import { join } from 'path'
21
+
22
+ const root = join(__dirname, '..')
23
+
24
+ /** The page names `route()` answers to, read as text - importing it would pull the whole client in. */
25
+ function routedPages(): string[] {
26
+ const source = readFileSync(join(root, 'client', 'route.ts'), 'utf-8')
27
+
28
+ return [...source.matchAll(/case '([^']+)':/g)].map(match => match[1])
29
+ }
30
+
31
+ /** The pages the shell can build, from the module's declaration. */
32
+ function pagesWithATagname(): string[] {
33
+ const config = readFileSync(join(root, 'things-factory.config.js'), 'utf-8')
34
+
35
+ return [...config.matchAll(/\{\s*tagname:\s*'[^']+',\s*page:\s*'([^']+)'\s*\}/g)].map(match => match[1])
36
+ }
37
+
38
+ describe('a routed screen can be built', () => {
39
+ it('reads both sides - an empty reading would let everything pass', () => {
40
+ expect(routedPages().length).toBeGreaterThanOrEqual(10)
41
+ expect(pagesWithATagname().length).toBeGreaterThanOrEqual(10)
42
+ })
43
+
44
+ it('★ every page this module routes to has a tagname the shell can build', () => {
45
+ const buildable = new Set(pagesWithATagname())
46
+ const blank = routedPages().filter(page => !buildable.has(page))
47
+
48
+ expect(blank).toEqual([])
49
+ })
50
+ })
@@ -0,0 +1,117 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { judgeApprovalLine, rolesNamedIn } from '../server/service/work-door/approval-line-standing.js'
5
+
6
+ const role = (value: string, participation?: string) => ({ type: 'Role', value, participation })
7
+
8
+ /**
9
+ * 자리를 사람으로 풀어 주는 포트. 이 판단은 DB 를 모르므로 시험도 DB 없이 선다.
10
+ *
11
+ * ⚠ `approval-role-empty` 와 `approval-person-absent` 는 **실 자료로 걸어 본 적이 없다.** 역할을
12
+ * 지목했는데 그 역할이 빈 결재선도, 없는 사람을 지목한 결재선도 지금 어느 테넌트에도 없다 —
13
+ * 미라텍은 역할이 0이라 결재선을 만들 재료가 없고, system 은 역할마다 사람이 있다. 여기가 그 둘을
14
+ * 잡는 유일한 자리다.
15
+ */
16
+ const peopleIn = (...ids: string[]) => ({
17
+ roleHasMembers: async (roleId: string) => ids.includes(roleId),
18
+ personIsHere: async (personId: string) => ids.includes(personId)
19
+ })
20
+
21
+ test('지목한 역할에 사람이 있으면 찬 것이다', async () => {
22
+ const standing = await judgeApprovalLine([role('r1'), role('r2')], peopleIn('r1', 'r2'))
23
+
24
+ assert.deepEqual(standing, { approvers: 2 })
25
+ })
26
+
27
+ test('결재선 자체가 없으면 approval-line-missing', async () => {
28
+ assert.deepEqual(await judgeApprovalLine([], peopleIn()), { approvers: 0, refusal: { code: 'approval-line-missing' } })
29
+ assert.deepEqual(await judgeApprovalLine(undefined, peopleIn()), { approvers: 0, refusal: { code: 'approval-line-missing' } })
30
+ })
31
+
32
+ /**
33
+ * 이것이 이 판단을 고친 이유다 — 앞서는 「찼다」로 읽고, 상신하는 순간 워크리스트가 던졌다.
34
+ */
35
+ test('역할은 있는데 사람이 없으면 approval-role-empty — 어느 역할인지 함께 낸다', async () => {
36
+ const standing = await judgeApprovalLine([role('r1'), role('r2')], peopleIn('r1'))
37
+
38
+ assert.deepEqual(standing, { approvers: 0, refusal: { code: 'approval-role-empty', params: { role: 'r2' } } })
39
+ })
40
+
41
+ test('역할을 지목해 놓고 값이 없으면 찬 것이 아니다', async () => {
42
+ const standing = await judgeApprovalLine([{ type: 'Role' }], peopleIn('r1'))
43
+
44
+ assert.ok(standing.refusal, '안 서는 결재선인데 사유가 없습니다')
45
+ })
46
+
47
+ /*
48
+ * ── 참조 ───────────────────────────────────────────────────────────────────
49
+ * 참조는 결재를 막지 않는다. 그래서 세지도 않고, 그 역할이 비었다고 거절하지도 않는다.
50
+ */
51
+ test('참조 자리는 사람이 없어도 막지 않는다', async () => {
52
+ const standing = await judgeApprovalLine([role('r1'), role('empty', 'reference')], peopleIn('r1'))
53
+
54
+ assert.deepEqual(standing, { approvers: 1 })
55
+ })
56
+
57
+ test('참조만 있는 결재선은 결재선이 없는 것이다 — 결재 없이 끝난다', async () => {
58
+ const standing = await judgeApprovalLine([role('r1', 'reference')], peopleIn('r1'))
59
+
60
+ assert.deepEqual(standing, { approvers: 0, refusal: { code: 'approval-line-missing' } })
61
+ })
62
+
63
+ test('participation 이 비어 있으면 결재다', async () => {
64
+ const standing = await judgeApprovalLine([{ type: 'Role', value: 'r1' }], peopleIn())
65
+
66
+ assert.deepEqual(standing, { approvers: 0, refusal: { code: 'approval-role-empty', params: { role: 'r1' } } })
67
+ })
68
+
69
+ /** 참조 자리의 역할은 **읽지도 않는다** — 읽으면 결재와 무관한 조회가 는다. */
70
+ test('참조 자리의 역할은 묻지 않는다', async () => {
71
+ const asked: string[] = []
72
+
73
+ await judgeApprovalLine([role('r1'), role('r9', 'reference')], {
74
+ roleHasMembers: async id => {
75
+ asked.push(id)
76
+ return true
77
+ },
78
+ personIsHere: async () => true
79
+ })
80
+
81
+ assert.deepEqual(asked, ['r1'])
82
+ })
83
+
84
+ /*
85
+ * ── 사람을 직접 지목한 자리 ────────────────────────────────────────────────
86
+ * 「이 제품은 역할만 쓴다」로 닫지 않는다. 닫으면 없는 사람을 지목한 결재선이 「찼다」로 그려지고,
87
+ * 역할에서 고친 그 모양이 사람 축으로 다시 난다.
88
+ */
89
+ test('지목한 사람이 있으면 찬 것이다', async () => {
90
+ const standing = await judgeApprovalLine([{ type: 'Employee', value: 'u1' }], peopleIn('u1'))
91
+
92
+ assert.deepEqual(standing, { approvers: 1 })
93
+ })
94
+
95
+ test('지목한 사람이 여기 없으면 approval-person-absent — 누구인지 함께 낸다', async () => {
96
+ const standing = await judgeApprovalLine([{ type: 'Employee', value: 'u1' }], peopleIn())
97
+
98
+ assert.deepEqual(standing, { approvers: 0, refusal: { code: 'approval-person-absent', params: { personId: 'u1' } } })
99
+ })
100
+
101
+ /** 상신할 때 그 사람으로 풀리는 자리다. 미리 셀 것이 없다. */
102
+ test('Myself 같은 상대 지목은 미리 묻지 않는다', async () => {
103
+ const standing = await judgeApprovalLine([{ type: 'Myself' }], peopleIn())
104
+
105
+ assert.deepEqual(standing, { approvers: 1 })
106
+ })
107
+
108
+ test('읽어야 할 역할만 골라 낸다 — 참조와 사람 지목은 뺀다', () => {
109
+ const named = rolesNamedIn([
110
+ role('r1'),
111
+ role('r2', 'reference'),
112
+ { type: 'Employee', value: 'u1' },
113
+ { type: 'Role' }
114
+ ])
115
+
116
+ assert.deepEqual(named, ['r1'])
117
+ })
@@ -0,0 +1,97 @@
1
+ import assert from 'node:assert/strict'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import { DOOR_KIND_WORD, DOOR_STANDING_WORD, kindWord, standingWord } from '../client/pages/work-door/door-words.js'
6
+
7
+ /*
8
+ * ═══════════════════════════════════════════════════════════════════════════
9
+ * registerEnumType puts the enum's MEMBER NAMES on the wire, not their values. So the server sends
10
+ * "Off" while the judge, the translation keys and the CSS classes all say "off". Nothing throws:
11
+ * i18next prints the key it could not find, and the class simply never matches.
12
+ *
13
+ * Measured on the running server before this was fixed: twelve rows reading
14
+ * "text.door-standing-Off", and every status pill falling back to the inherited text colour because
15
+ * .mark.off never matched .mark.Off.
16
+ *
17
+ * These tests read the enum out of the server source rather than importing it - that module carries
18
+ * decorators, which the test harness cannot load.
19
+ * ═══════════════════════════════════════════════════════════════════════════
20
+ */
21
+
22
+ const TYPE_SOURCE = readFileSync(
23
+ join(__dirname, '..', 'server', 'service', 'work-door', 'work-door-type.ts'),
24
+ 'utf-8'
25
+ )
26
+
27
+ /** `Off = 'off'` → ['Off', 'off'], for the one enum named. */
28
+ function members(enumName: string): [string, string][] {
29
+ const block = TYPE_SOURCE.split(`export enum ${enumName} {`)[1]
30
+
31
+ assert.ok(block, `${enumName} 을 찾지 못했습니다 — 이름이 바뀌었으면 이 시험도 함께 고쳐야 합니다`)
32
+
33
+ return [...block.split('\n}')[0].matchAll(/^\s*([A-Za-z]+)\s*=\s*'([^']+)'/gm)].map(one => [one[1], one[2]])
34
+ }
35
+
36
+ test('every standing the server can send has a word here', () => {
37
+ const declared = members('DoorStandingValue')
38
+
39
+ assert.ok(declared.length >= 4, `상태를 ${declared.length}개만 읽었습니다`)
40
+
41
+ for (const [wire, value] of declared) {
42
+ assert.equal(standingWord(wire), value, `${wire} 이 전선으로 오면 화면이 못 읽습니다`)
43
+ }
44
+ })
45
+
46
+ test('every kind the server can send has a word here', () => {
47
+ const declared = members('DoorKindValue')
48
+
49
+ assert.ok(declared.length >= 2, `종류를 ${declared.length}개만 읽었습니다`)
50
+
51
+ for (const [wire, value] of declared) {
52
+ assert.equal(kindWord(wire), value, `${wire} 이 전선으로 오면 화면이 못 읽습니다`)
53
+ }
54
+ })
55
+
56
+ test('nothing extra is mapped — a dead entry hides that the server dropped a value', () => {
57
+ const standings = new Set(members('DoorStandingValue').map(one => one[0]))
58
+ const kinds = new Set(members('DoorKindValue').map(one => one[0]))
59
+
60
+ assert.deepEqual(Object.keys(DOOR_STANDING_WORD).filter(one => !standings.has(one)), [])
61
+ assert.deepEqual(Object.keys(DOOR_KIND_WORD).filter(one => !kinds.has(one)), [])
62
+ })
63
+
64
+ test('an unknown spelling answers nothing — the screen must not paint a guess', () => {
65
+ assert.equal(standingWord('Whatever'), undefined)
66
+ assert.equal(kindWord('Whatever'), undefined)
67
+ })
68
+
69
+ /*
70
+ * The word is not enough on its own: the screen builds `text.door-standing-<word>` and a CSS class
71
+ * from it. Either one missing is silent.
72
+ */
73
+ test('every word has a line to show for it, in every language we ship', () => {
74
+ const missing: string[] = []
75
+
76
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
77
+ const said = JSON.parse(
78
+ readFileSync(join(__dirname, '..', 'translations', `${lang}.json`), 'utf-8')
79
+ ) as Record<string, string>
80
+
81
+ for (const word of Object.values(DOOR_STANDING_WORD)) {
82
+ if (!said[`text.door-standing-${word}`]) missing.push(`${lang} · text.door-standing-${word}`)
83
+ }
84
+ for (const word of Object.values(DOOR_KIND_WORD)) {
85
+ if (!said[`text.door-kind-${word}`]) missing.push(`${lang} · text.door-kind-${word}`)
86
+ }
87
+ }
88
+
89
+ assert.deepEqual(missing, [], `\n없는 문구는 화면에 키 글자로 뜹니다:\n${missing.join('\n')}`)
90
+ })
91
+
92
+ test('every word has a colour to paint — a class nothing styles is invisible, not absent', () => {
93
+ const styles = readFileSync(join(__dirname, '..', 'client', 'pages', 'work-door', 'work-doors-page.ts'), 'utf-8')
94
+ const missing = Object.values(DOOR_STANDING_WORD).filter(word => !styles.includes(`.${word}`))
95
+
96
+ assert.deepEqual(missing, [], `\n이 상태에는 칠할 것이 없습니다: ${missing.join(' · ')}`)
97
+ })
@@ -0,0 +1,222 @@
1
+ import { strict as assert } from 'node:assert'
2
+
3
+ import { doorsToFill, judgeDoor, summarizeDoors } from '../server/service/work-door/work-door.js'
4
+
5
+ /**
6
+ * An empty line is a choice, not a defect — but nothing said which doors were in that state.
7
+ * Miratek had all eleven installed and all eleven empty (measured 2026-09-11), so corrections,
8
+ * scrapping and dispositions all took effect there with nobody approving.
9
+ */
10
+ const approval = (label: string, over: object = {}) => ({ name: 'x', label, kind: 'approval' as const, ...over })
11
+ const assignment = (label: string, over: object = {}) => ({ name: 'x', label, kind: 'assignment' as const, ...over })
12
+
13
+ describe('work door standing', () => {
14
+ /**
15
+ * A door that borrows another door's line has no line of its own to fill. If the sentence did not say
16
+ * so, an administrator would go to that door to fill a line the raise never reads (ADR-0047 ⑨).
17
+ */
18
+ it('says where the line is set when a door follows another door', () => {
19
+ const standing = judgeDoor(
20
+ approval('통제 해제 승인', {
21
+ follows: '화면 발행 승인',
22
+ installed: { approvalLine: [{}] },
23
+ approvalStanding: { approvers: 1 }
24
+ })
25
+ )
26
+ const empty = judgeDoor(
27
+ approval('통제 해제 승인', {
28
+ follows: '화면 발행 승인',
29
+ installed: {},
30
+ approvalStanding: { approvers: 0, refusal: { code: 'approval-line-missing' } }
31
+ })
32
+ )
33
+
34
+ assert.equal(standing.standing, 'on')
35
+ assert.ok(standing.because.includes('「화면 발행 승인」 의 결재선을 따르므로'))
36
+ assert.equal(empty.standing, 'off')
37
+ assert.ok(empty.because.includes('「화면 발행 승인」 의 결재선을 따르므로'), empty.because)
38
+ })
39
+
40
+ it('reads a filled approval line as standing, and says how many it passes', () => {
41
+ const door = judgeDoor(
42
+ approval('실적 정정 승인', {
43
+ installed: { approvalLine: [{}, {}] },
44
+ approvalStanding: { approvers: 2 }
45
+ })
46
+ )
47
+
48
+ assert.equal(door.standing, 'on')
49
+ assert.equal(door.approvers, 2)
50
+ assert.ok(door.because.includes('결재자 2명'))
51
+ })
52
+
53
+ /**
54
+ * A line that names a role nobody holds counts as no line at all — the screen used to read it as
55
+ * standing and the raise then threw `approver is not set` (ADR-0047 ③).
56
+ */
57
+ it('reads a role that holds people as standing', () => {
58
+ const door = judgeDoor(
59
+ approval('출하 승인', {
60
+ installed: { approvalLine: [{ type: 'Role', value: 'r1' }] },
61
+ approvalStanding: { approvers: 1 }
62
+ })
63
+ )
64
+
65
+ assert.equal(door.standing, 'on')
66
+ assert.equal(door.approvers, 1)
67
+ })
68
+
69
+ /**
70
+ * The administrator's next move differs: filling a line is not the same job as putting a person
71
+ * in a role, and saying the first sends them back to a line they already set.
72
+ */
73
+ it('says an empty role needs a person, not a new approval line', () => {
74
+ const door = judgeDoor(
75
+ approval('출하 승인', {
76
+ installed: { approvalLine: [{ type: 'Role', value: 'r1' }] },
77
+ approvalStanding: { approvers: 0, refusal: { code: 'approval-role-empty', params: { role: 'r1' } } }
78
+ })
79
+ )
80
+
81
+ assert.equal(door.standing, 'off')
82
+ assert.equal(door.approvers, 0)
83
+ assert.ok(door.because.includes('역할에 사람이 없습니다'))
84
+ assert.ok(!door.because.includes('자기 건을 승인한'))
85
+ })
86
+
87
+ /** A named person who is not here is the same defect on the other axis (ADR-0047 ③). */
88
+ it('says a named person who is gone needs a different name, not a new line', () => {
89
+ const door = judgeDoor(
90
+ approval('출하 승인', {
91
+ installed: { approvalLine: [{ type: 'Employee', value: 'u1' }] },
92
+ approvalStanding: { approvers: 0, refusal: { code: 'approval-person-absent', params: { personId: 'u1' } } }
93
+ })
94
+ )
95
+
96
+ assert.equal(door.standing, 'off')
97
+ assert.ok(door.because.includes('지목한 사람이 이 공장에 없습니다'))
98
+ assert.ok(!door.because.includes('자기 건을 승인한'))
99
+ })
100
+
101
+ /** Not having read the line must not read as "the line is filled". */
102
+ it('treats an unread approval line as empty rather than filled', () => {
103
+ const door = judgeDoor(approval('출하 승인', { installed: { approvalLine: [{ type: 'Role', value: 'r1' }] } }))
104
+
105
+ assert.equal(door.standing, 'off')
106
+ })
107
+
108
+ /**
109
+ * Worklist completes the instance on issue when the line is empty, so the person raising it has
110
+ * approved their own. "No approval happens" would be the wrong thing to tell an administrator.
111
+ */
112
+ it('says an empty approval line means the raiser approves their own', () => {
113
+ const door = judgeDoor(approval('폐기 실행 승인', { installed: { approvalLine: [] } }))
114
+
115
+ assert.equal(door.standing, 'off')
116
+ assert.ok(door.because.includes('자기 건을 승인한'))
117
+ })
118
+
119
+ /**
120
+ * The two kinds fail in opposite directions — an empty approval passes through, an unassigned
121
+ * door reaches nobody. One shared sentence would leave the reader with the wrong action.
122
+ */
123
+ it('says an unassigned door reaches nobody, not that it passes through', () => {
124
+ const door = judgeDoor(assignment('작업 지시 배정', { installed: { assigneeRole: undefined } }))
125
+
126
+ assert.equal(door.standing, 'off')
127
+ assert.ok(door.because.includes('아무에게도 가지 않습니다'))
128
+ assert.ok(!door.because.includes('자기 건을 승인'))
129
+ })
130
+
131
+ it('reads a bound role as standing and names it', () => {
132
+ const door = judgeDoor(assignment('정비 배정', { installed: { assigneeRole: 'MAINTENANCE-LEAD' } }))
133
+
134
+ assert.equal(door.standing, 'on')
135
+ assert.ok(door.because.includes('MAINTENANCE-LEAD'))
136
+ })
137
+
138
+ /** An approval line on an assignment door is not what fills it, and must not read as standing. */
139
+ it('does not let an approval line stand in for an assignee role', () => {
140
+ assert.equal(judgeDoor(assignment('배정', { installed: { approvalLine: [{}, {}] } })).standing, 'off')
141
+ })
142
+
143
+ it('treats a line that is absent the same as one that is empty', () => {
144
+ assert.equal(judgeDoor(approval('Y', { installed: {} })).standing, 'off')
145
+ })
146
+
147
+ /** A door that never installed is our failure, not the plant's choice, and must not read alike. */
148
+ it('separates a door that never installed from one the plant left empty', () => {
149
+ const door = judgeDoor(approval('초품 검사 승인'))
150
+
151
+ assert.equal(door.standing, 'missing')
152
+ assert.ok(door.because.includes('세우지 못한 것'))
153
+ })
154
+
155
+ describe('a plant that says it does not use one', () => {
156
+ /**
157
+ * Without this the survey nags for ever, and people stop reading it — and then one genuinely
158
+ * empty door is lost in the nagging (architect, 2026-09-11).
159
+ */
160
+ it('reads as not-used and carries the reason', () => {
161
+ const door = judgeDoor(approval('폐기 실행 승인', { notUsedBecause: '폐기는 본사가 처리합니다' }))
162
+
163
+ assert.equal(door.standing, 'not-used')
164
+ assert.ok(door.because.includes('폐기는 본사가 처리합니다'))
165
+ })
166
+
167
+ /** Said first, or a plant that opted out keeps being told its missing form is a defect. */
168
+ it('wins over a form that never installed', () => {
169
+ const door = judgeDoor(approval('폐기 실행 승인', { notUsedBecause: '안 씁니다', installed: undefined }))
170
+
171
+ assert.equal(door.standing, 'not-used')
172
+ assert.ok(!door.because.includes('결함'))
173
+ })
174
+
175
+ it('is never counted as something to fill', () => {
176
+ const doors = [
177
+ judgeDoor(approval('a', { notUsedBecause: '안 씁니다' })),
178
+ judgeDoor(approval('b', { installed: { approvalLine: [] } }))
179
+ ]
180
+
181
+ assert.equal(doorsToFill(doors), 1)
182
+ })
183
+ })
184
+ })
185
+
186
+ describe('summary', () => {
187
+ const on = judgeDoor(approval('A', { installed: { approvalLine: [{}] }, approvalStanding: { approvers: 1 } }))
188
+ const off = judgeDoor(approval('B', { installed: { approvalLine: [] } }))
189
+ const missing = judgeDoor(approval('C'))
190
+ const notUsed = judgeDoor(approval('D', { notUsedBecause: '안 씁니다' }))
191
+
192
+ it('says everything stands when it does', () => {
193
+ const said = summarizeDoors([on, on])
194
+
195
+ assert.ok(said.includes('2개가 다 서 있습니다'))
196
+ assert.ok(!said.includes('결함'))
197
+ })
198
+
199
+ /** Zero left to fill has to read as done even when some doors were opted out. */
200
+ it('reads as done when the only unfilled ones were opted out', () => {
201
+ const said = summarizeDoors([on, notUsed])
202
+
203
+ assert.ok(said.includes('채울 것이 남지 않았습니다'))
204
+ assert.ok(said.includes('1개는 이 공장이 쓰지 않기로'))
205
+ })
206
+
207
+ it('leads with how many are left to fill', () => {
208
+ const said = summarizeDoors([on, off, off, notUsed])
209
+
210
+ assert.ok(said.startsWith('채울 것이 2개 남았습니다.'))
211
+ assert.ok(said.includes('1개는 이 공장이 쓰지 않기로 한 것이라 세지 않았습니다'))
212
+ })
213
+
214
+ it('names a door that never installed as a defect', () => {
215
+ assert.ok(summarizeDoors([on, missing]).includes('설정이 아니라 결함'))
216
+ })
217
+
218
+ /** Zero doors is a different fact from "none of them stand" and must not be reported as it. */
219
+ it('does not report an empty product as a plant with nothing standing', () => {
220
+ assert.equal(summarizeDoors([]), '이 제품이 세우는 업무 양식이 없습니다.')
221
+ })
222
+ })
@@ -28,6 +28,11 @@ export default {
28
28
  { tagname: 'activity-thread-list-page', page: 'activity-thread-list' },
29
29
  { tagname: 'installable-activity-list-page', page: 'installable-activity-list' },
30
30
  { tagname: 'activity-approval-list-page', page: 'activity-approval-list' },
31
- { tagname: 'activity-stats-list-page', page: 'activity-stats-list' }
31
+ { tagname: 'activity-stats-list-page', page: 'activity-stats-list' },
32
+ /*
33
+ * 셸은 이 짝이 있어야 요소를 만든다. `route()` 가 이름을 알아도 여기 없으면 **오류 없이 빈 화면**이다.
34
+ * operato-plant 가 두 번 밟았고(2026-09-03 · 09-15), 이 화면이 그 제품에서 옮겨 올 때 세 번째였다.
35
+ */
36
+ { tagname: 'work-doors-page', page: 'work-doors-page' }
32
37
  ]
33
38
  }
@@ -233,6 +233,12 @@
233
233
  "text.decision-keys-count": "{n} decision key(s)",
234
234
  "text.door-filled-from": "set in {name}",
235
235
  "text.door-form-from": "{name}'s form — it cannot be changed from here",
236
+ "text.door-kind-approval": "approval",
237
+ "text.door-kind-assignment": "assignment",
238
+ "text.door-standing-missing": "form not installed",
239
+ "text.door-standing-not-used": "not used",
240
+ "text.door-standing-off": "no approvers",
241
+ "text.door-standing-on": "in use",
236
242
  "text.draft": "draft",
237
243
  "text.due-just-passed": "just passed",
238
244
  "text.due-left-in": "{span} left",
@@ -233,6 +233,12 @@
233
233
  "text.decision-keys-count": "判断項目 {n}件",
234
234
  "text.door-filled-from": "{name} で設定されています",
235
235
  "text.door-form-from": "{name} のフォームです — ここでは変更できません",
236
+ "text.door-kind-approval": "承認",
237
+ "text.door-kind-assignment": "割当",
238
+ "text.door-standing-missing": "様式なし",
239
+ "text.door-standing-not-used": "使用しない",
240
+ "text.door-standing-off": "承認者なし",
241
+ "text.door-standing-on": "使用中",
236
242
  "text.draft": "ドラフト保存",
237
243
  "text.due-just-passed": "過ぎたところ",
238
244
  "text.due-left-in": "残り{span}",
@@ -233,6 +233,12 @@
233
233
  "text.decision-keys-count": "판단 항목 {n}개",
234
234
  "text.door-filled-from": "{name} 에서 정했습니다",
235
235
  "text.door-form-from": "{name} 의 양식입니다 — 여기서는 못 고칩니다",
236
+ "text.door-kind-approval": "결재",
237
+ "text.door-kind-assignment": "배정",
238
+ "text.door-standing-missing": "양식 없음",
239
+ "text.door-standing-not-used": "안 씀",
240
+ "text.door-standing-off": "결재자 없음",
241
+ "text.door-standing-on": "사용 중",
236
242
  "text.draft": "초안저장",
237
243
  "text.due-just-passed": "방금 지남",
238
244
  "text.due-left-in": "{span} 남음",
@@ -233,6 +233,12 @@
233
233
  "text.decision-keys-count": "{n} kunci keputusan",
234
234
  "text.door-filled-from": "ditetapkan di {name}",
235
235
  "text.door-form-from": "Borang {name} — tidak boleh diubah dari sini",
236
+ "text.door-kind-approval": "kelulusan",
237
+ "text.door-kind-assignment": "tugasan",
238
+ "text.door-standing-missing": "borang tiada",
239
+ "text.door-standing-not-used": "tidak digunakan",
240
+ "text.door-standing-off": "tiada pelulus",
241
+ "text.door-standing-on": "sedang digunakan",
236
242
  "text.draft": "draf",
237
243
  "text.due-just-passed": "baru lewat",
238
244
  "text.due-left-in": "tinggal {span}",
@@ -233,6 +233,12 @@
233
233
  "text.decision-keys-count": "判断项 {n} 个",
234
234
  "text.door-filled-from": "在 {name} 中设定",
235
235
  "text.door-form-from": "{name} 的表单 — 此处无法修改",
236
+ "text.door-kind-approval": "审批",
237
+ "text.door-kind-assignment": "指派",
238
+ "text.door-standing-missing": "无样式",
239
+ "text.door-standing-not-used": "不使用",
240
+ "text.door-standing-off": "无审批人",
241
+ "text.door-standing-on": "使用中",
236
242
  "text.draft": "草稿",
237
243
  "text.due-just-passed": "刚刚超期",
238
244
  "text.due-left-in": "剩余{span}",