@things-factory/worklist 10.1.29 → 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.
Files changed (65) hide show
  1. package/dist-client/pages/work-door/door-words.d.ts +41 -0
  2. package/dist-client/pages/work-door/door-words.js +54 -0
  3. package/dist-client/pages/work-door/door-words.js.map +1 -0
  4. package/dist-client/pages/work-door/work-doors-page.d.ts +37 -0
  5. package/dist-client/pages/work-door/work-doors-page.js +369 -0
  6. package/dist-client/pages/work-door/work-doors-page.js.map +1 -0
  7. package/dist-client/route.d.ts +1 -1
  8. package/dist-client/route.js +3 -0
  9. package/dist-client/route.js.map +1 -1
  10. package/dist-client/tsconfig.tsbuildinfo +1 -1
  11. package/dist-server/service/index.d.ts +13 -2
  12. package/dist-server/service/index.js +25 -2
  13. package/dist-server/service/index.js.map +1 -1
  14. package/dist-server/service/work-door/activity-lookup.d.ts +70 -0
  15. package/dist-server/service/work-door/activity-lookup.js +110 -0
  16. package/dist-server/service/work-door/activity-lookup.js.map +1 -0
  17. package/dist-server/service/work-door/approval-line-standing.d.ts +92 -0
  18. package/dist-server/service/work-door/approval-line-standing.js +47 -0
  19. package/dist-server/service/work-door/approval-line-standing.js.map +1 -0
  20. package/dist-server/service/work-door/index.d.ts +15 -0
  21. package/dist-server/service/work-door/index.js +20 -0
  22. package/dist-server/service/work-door/index.js.map +1 -0
  23. package/dist-server/service/work-door/pass-work-door.d.ts +74 -0
  24. package/dist-server/service/work-door/pass-work-door.js +45 -0
  25. package/dist-server/service/work-door/pass-work-door.js.map +1 -0
  26. package/dist-server/service/work-door/work-door-declaration.d.ts +114 -0
  27. package/dist-server/service/work-door/work-door-declaration.js +67 -0
  28. package/dist-server/service/work-door/work-door-declaration.js.map +1 -0
  29. package/dist-server/service/work-door/work-door-event.d.ts +20 -0
  30. package/dist-server/service/work-door/work-door-event.js +25 -0
  31. package/dist-server/service/work-door/work-door-event.js.map +1 -0
  32. package/dist-server/service/work-door/work-door-journal-service.d.ts +47 -0
  33. package/dist-server/service/work-door/work-door-journal-service.js +75 -0
  34. package/dist-server/service/work-door/work-door-journal-service.js.map +1 -0
  35. package/dist-server/service/work-door/work-door-journal.d.ts +30 -0
  36. package/dist-server/service/work-door/work-door-journal.js +94 -0
  37. package/dist-server/service/work-door/work-door-journal.js.map +1 -0
  38. package/dist-server/service/work-door/work-door-not-used.d.ts +39 -0
  39. package/dist-server/service/work-door/work-door-not-used.js +86 -0
  40. package/dist-server/service/work-door/work-door-not-used.js.map +1 -0
  41. package/dist-server/service/work-door/work-door-resolver.d.ts +29 -0
  42. package/dist-server/service/work-door/work-door-resolver.js +127 -0
  43. package/dist-server/service/work-door/work-door-resolver.js.map +1 -0
  44. package/dist-server/service/work-door/work-door-service.d.ts +29 -0
  45. package/dist-server/service/work-door/work-door-service.js +126 -0
  46. package/dist-server/service/work-door/work-door-service.js.map +1 -0
  47. package/dist-server/service/work-door/work-door-type.d.ts +49 -0
  48. package/dist-server/service/work-door/work-door-type.js +122 -0
  49. package/dist-server/service/work-door/work-door-type.js.map +1 -0
  50. package/dist-server/service/work-door/work-door.d.ts +138 -0
  51. package/dist-server/service/work-door/work-door.js +156 -0
  52. package/dist-server/service/work-door/work-door.js.map +1 -0
  53. package/dist-server/tsconfig.tsbuildinfo +1 -1
  54. package/package.json +10 -10
  55. package/tests/pass-work-door.test.ts +133 -0
  56. package/tests/screen-seam.test.ts +50 -0
  57. package/tests/work-door-approval-line-standing.test.ts +117 -0
  58. package/tests/work-door-door-words.test.ts +97 -0
  59. package/tests/work-door.test.ts +222 -0
  60. package/things-factory.config.js +6 -1
  61. package/translations/en.json +18 -1
  62. package/translations/ja.json +18 -1
  63. package/translations/ko.json +18 -1
  64. package/translations/ms.json +18 -1
  65. package/translations/zh.json +18 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/worklist",
3
- "version": "10.1.29",
3
+ "version": "10.1.32",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -37,14 +37,14 @@
37
37
  "@operato/moment-timezone-es": "^10.0.0",
38
38
  "@operato/p13n": "^10.0.0",
39
39
  "@operato/popup": "^10.15.0",
40
- "@things-factory/attachment-base": "^10.1.29",
41
- "@things-factory/auth-base": "^10.1.29",
42
- "@things-factory/board-service": "^10.1.29",
43
- "@things-factory/context-ui": "^10.1.29",
44
- "@things-factory/organization": "^10.1.29",
45
- "@things-factory/personalization": "^10.1.29",
46
- "@things-factory/scheduler-client": "^10.1.29",
47
- "@things-factory/shell": "^10.1.29"
40
+ "@things-factory/attachment-base": "^10.1.31",
41
+ "@things-factory/auth-base": "^10.1.31",
42
+ "@things-factory/board-service": "^10.1.31",
43
+ "@things-factory/context-ui": "^10.1.31",
44
+ "@things-factory/organization": "^10.1.32",
45
+ "@things-factory/personalization": "^10.1.31",
46
+ "@things-factory/scheduler-client": "^10.1.31",
47
+ "@things-factory/shell": "^10.1.31"
48
48
  },
49
- "gitHead": "e6bf9dec003fe5e794746aaeb5852b3b50478232"
49
+ "gitHead": "67b211e001da3a923e562cb048e4691bd961315c"
50
50
  }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Which way a work door goes when its approval line is empty, and what gets written down.
3
+ *
4
+ * ── Why each of these ─────────────────────────────────────────────────────
5
+ * The branch itself was never in one place before: eight doors in operato-plant each read the line
6
+ * and decided, and none of them wrote anything, so a step skipped for want of an approver left no
7
+ * trace at all. Joined here, `run` cannot happen without the line being written - so the test that
8
+ * matters most is that the `run` branch writes exactly one row, with the reason that applies.
9
+ *
10
+ * The transaction check is not ceremony: a plain manager commits the row on its own, and then the
11
+ * log says something happened that the failing work never did.
12
+ */
13
+
14
+ /*
15
+ * The entity is stood in for. Its decorators need `emitDecoratorMetadata`, which babel does not
16
+ * give jest (the repository's jest config says so, and says to test the pure logic on its own). The
17
+ * branch under test never reads the class - it only names the repository to save into.
18
+ */
19
+ jest.mock('../server/service/work-door/work-door-journal.js', () => ({ WorkDoorJournal: class WorkDoorJournal {} }))
20
+
21
+ import { WorkDoorEvent } from '../server/service/work-door/work-door-event.js'
22
+ import { passWorkDoor } from '../server/service/work-door/pass-work-door.js'
23
+
24
+ const domain = { id: 'd1' } as any
25
+
26
+ /** A manager that looks transactional and remembers what was saved. */
27
+ function openTx() {
28
+ const saved: any[] = []
29
+
30
+ return {
31
+ saved,
32
+ queryRunner: { isTransactionActive: true },
33
+ getRepository: () => ({ save: async (row: any) => ((saved.push(row), { ...row, id: 'j1' }) as any) })
34
+ } as any
35
+ }
36
+
37
+ const seatsThatStand = { roleHasMembers: async () => true, personIsHere: async () => true }
38
+
39
+ const approvalDoor = (over: any = {}): any =>
40
+ ({ name: 'lot-hold-release', labelKey: 'activity.lot-hold-release.label', kind: 'approval', whenNoLine: 'run', ...over } as any)
41
+
42
+ const subject = { kind: 'lot', id: 'LOT-1' }
43
+
44
+ describe('passWorkDoor', () => {
45
+ it('refuses to run outside a transaction - the row would commit on its own', async () => {
46
+ const loose = { getRepository: () => ({ save: async (r: any) => r }) } as any
47
+
48
+ await expect(
49
+ passWorkDoor({ door: approvalDoor(), subject }, { tx: loose, domain, seats: seatsThatStand })
50
+ ).rejects.toThrow(/transaction/)
51
+ })
52
+
53
+ it('will not judge an assignment door - it waits for a role, not for a line', async () => {
54
+ const door = { name: 'job-order-dispatch', labelKey: 'k', kind: 'assignment' } as any
55
+
56
+ await expect(passWorkDoor({ door, subject }, { tx: openTx(), domain, seats: seatsThatStand })).rejects.toThrow(
57
+ /assignment door/
58
+ )
59
+ })
60
+
61
+ it('raises when the line stands, and writes nothing - the instance is the record', async () => {
62
+ const tx = openTx()
63
+ const activity = { approvalLine: [{ type: 'Role', value: 'r1' }] }
64
+
65
+ const verdict = await passWorkDoor({ door: approvalDoor(), activity, subject }, { tx, domain, seats: seatsThatStand })
66
+
67
+ expect(verdict.go).toBe('raise')
68
+ expect(tx.saved).toHaveLength(0)
69
+ })
70
+
71
+ it('runs with an empty line, and that is written down once', async () => {
72
+ const tx = openTx()
73
+
74
+ const verdict = await passWorkDoor({ door: approvalDoor(), subject }, { tx, domain, seats: seatsThatStand })
75
+
76
+ expect(verdict).toMatchObject({ go: 'run', because: WorkDoorEvent.RanWithoutLine })
77
+ expect(tx.saved).toHaveLength(1)
78
+ expect(tx.saved[0]).toMatchObject({
79
+ kind: WorkDoorEvent.RanWithoutLine,
80
+ door: 'lot-hold-release',
81
+ subjectKind: 'lot',
82
+ subjectId: 'LOT-1'
83
+ })
84
+ })
85
+
86
+ it('says so differently when the door was declared unused - the administrator has nothing to fix', async () => {
87
+ const tx = openTx()
88
+
89
+ const verdict = await passWorkDoor({ door: approvalDoor(), subject, notUsed: true }, { tx, domain, seats: seatsThatStand })
90
+
91
+ expect(verdict).toMatchObject({ go: 'run', because: WorkDoorEvent.RanNotUsed })
92
+ expect(tx.saved[0].kind).toBe(WorkDoorEvent.RanNotUsed)
93
+ })
94
+
95
+ it('refuses with the line\'s own reason where the door refuses, and writes nothing', async () => {
96
+ const tx = openTx()
97
+ const door = approvalDoor({ whenNoLine: 'refuse', because: 'irreversible' } as any)
98
+
99
+ const verdict: any = await passWorkDoor({ door, subject }, { tx, domain, seats: seatsThatStand })
100
+
101
+ expect(verdict.go).toBe('refuse')
102
+ expect(verdict.refusal.code).toBe('approval-line-missing')
103
+ expect(tx.saved).toHaveLength(0)
104
+ })
105
+
106
+ describe('as-established - the row decides, not the door', () => {
107
+ const door = approvalDoor({ name: 'non-working-withdrawal', whenNoLine: 'as-established', because: 'approval-is-the-act' } as any)
108
+
109
+ it('refuses when the row was established through approval', async () => {
110
+ const tx = openTx()
111
+
112
+ const verdict: any = await passWorkDoor(
113
+ { door, subject, establishedByApproval: true },
114
+ { tx, domain, seats: seatsThatStand }
115
+ )
116
+
117
+ expect(verdict.go).toBe('refuse')
118
+ expect(tx.saved).toHaveLength(0)
119
+ })
120
+
121
+ it('runs when it stood without approval', async () => {
122
+ const tx = openTx()
123
+
124
+ const verdict: any = await passWorkDoor(
125
+ { door, subject, establishedByApproval: false },
126
+ { tx, domain, seats: seatsThatStand }
127
+ )
128
+
129
+ expect(verdict.go).toBe('run')
130
+ expect(tx.saved).toHaveLength(1)
131
+ })
132
+ })
133
+ })
@@ -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
  }