@things-factory/organization 10.1.223 → 10.1.227
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/pages/employee-profile/employee-profile-files.d.ts +23 -0
- package/dist-client/pages/employee-profile/employee-profile-files.js +50 -0
- package/dist-client/pages/employee-profile/employee-profile-files.js.map +1 -0
- package/dist-client/pages/employee-profile/employee-profile-form.d.ts +63 -0
- package/dist-client/pages/employee-profile/employee-profile-form.js +132 -0
- package/dist-client/pages/employee-profile/employee-profile-form.js.map +1 -0
- package/dist-client/pages/employee-profile/employee-profile-list-page.d.ts +52 -0
- package/dist-client/pages/employee-profile/employee-profile-list-page.js +206 -0
- package/dist-client/pages/employee-profile/employee-profile-list-page.js.map +1 -0
- package/dist-client/pages/employee-profile/employee-profile-preview.d.ts +28 -0
- package/dist-client/pages/employee-profile/employee-profile-preview.js +179 -0
- package/dist-client/pages/employee-profile/employee-profile-preview.js.map +1 -0
- package/dist-client/pages/employee-profile/my-employee-profile-page.d.ts +56 -0
- package/dist-client/pages/employee-profile/my-employee-profile-page.js +656 -0
- package/dist-client/pages/employee-profile/my-employee-profile-page.js.map +1 -0
- package/dist-client/route.d.ts +1 -1
- package/dist-client/route.js +7 -0
- package/dist-client/route.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/ai-entry/employee-profile-entry.d.ts +10 -0
- package/dist-server/ai-entry/employee-profile-entry.js +88 -0
- package/dist-server/ai-entry/employee-profile-entry.js.map +1 -0
- package/dist-server/ai-entry/entry-target-shape.d.ts +11 -1
- package/dist-server/ai-entry/entry-target-shape.js.map +1 -1
- package/dist-server/ai-entry/organization-entry-targets.d.ts +8 -0
- package/dist-server/ai-entry/organization-entry-targets.js +26 -1
- package/dist-server/ai-entry/organization-entry-targets.js.map +1 -1
- package/dist-server/role-tasks.js +4 -1
- package/dist-server/role-tasks.js.map +1 -1
- package/dist-server/service/employee-profile/employee-profile-document.d.ts +122 -0
- package/dist-server/service/employee-profile/employee-profile-document.js +439 -0
- package/dist-server/service/employee-profile/employee-profile-document.js.map +1 -0
- package/dist-server/service/employee-profile/employee-profile-mutation.d.ts +6 -0
- package/dist-server/service/employee-profile/employee-profile-mutation.js +74 -0
- package/dist-server/service/employee-profile/employee-profile-mutation.js.map +1 -0
- package/dist-server/service/employee-profile/employee-profile-query.d.ts +14 -0
- package/dist-server/service/employee-profile/employee-profile-query.js +137 -0
- package/dist-server/service/employee-profile/employee-profile-query.js.map +1 -0
- package/dist-server/service/employee-profile/employee-profile-rows.d.ts +29 -0
- package/dist-server/service/employee-profile/employee-profile-rows.js +122 -0
- package/dist-server/service/employee-profile/employee-profile-rows.js.map +1 -0
- package/dist-server/service/employee-profile/employee-profile-rules.d.ts +61 -0
- package/dist-server/service/employee-profile/employee-profile-rules.js +104 -0
- package/dist-server/service/employee-profile/employee-profile-rules.js.map +1 -0
- package/dist-server/service/employee-profile/employee-profile-type.d.ts +35 -0
- package/dist-server/service/employee-profile/employee-profile-type.js +110 -0
- package/dist-server/service/employee-profile/employee-profile-type.js.map +1 -0
- package/dist-server/service/employee-profile/employee-profile.d.ts +24 -0
- package/dist-server/service/employee-profile/employee-profile.js +112 -0
- package/dist-server/service/employee-profile/employee-profile.js.map +1 -0
- package/dist-server/service/employee-profile/index.d.ts +5 -0
- package/dist-server/service/employee-profile/index.js +9 -0
- package/dist-server/service/employee-profile/index.js.map +1 -0
- package/dist-server/service/index.d.ts +3 -2
- package/dist-server/service/index.js +6 -2
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -6
- package/tests/employee-profile-db.test.ts +247 -0
- package/tests/employee-profile-document.test.ts +219 -0
- package/tests/employee-profile-entry.test.ts +72 -0
- package/tests/employee-profile-rules.test.ts +103 -0
- package/things-factory.config.js +3 -0
- package/translations/en.json +78 -1
- package/translations/es.json +78 -1
- package/translations/ja.json +78 -1
- package/translations/ko.json +78 -1
- package/translations/ms.json +78 -1
- package/translations/zh.json +78 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/organization",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.227",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -36,10 +36,12 @@
|
|
|
36
36
|
"@operato/p13n": "^10.0.0",
|
|
37
37
|
"@operato/popup": "^10.15.0",
|
|
38
38
|
"@operato/shell": "^10.0.0",
|
|
39
|
-
"@things-factory/auth-base": "^10.1.
|
|
40
|
-
"@things-factory/code-ui": "^10.1.
|
|
41
|
-
"@things-factory/contact": "^10.1.
|
|
42
|
-
"@things-factory/
|
|
39
|
+
"@things-factory/auth-base": "^10.1.227",
|
|
40
|
+
"@things-factory/code-ui": "^10.1.227",
|
|
41
|
+
"@things-factory/contact": "^10.1.227",
|
|
42
|
+
"@things-factory/pdf": "^10.1.227",
|
|
43
|
+
"@things-factory/shell": "^10.1.227",
|
|
44
|
+
"exceljs": "4.2.0"
|
|
43
45
|
},
|
|
44
|
-
"gitHead": "
|
|
46
|
+
"gitHead": "f66591e3864aab08a6fb93fb1fcf1c9de6b546ac"
|
|
45
47
|
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The profile's doors, through GraphQL on sqlite (ADR-0169 decisions 3 · 4): the employee writes their own and nobody
|
|
3
|
+
* else's, an account not linked to an employee has none, and the list gives every active employee with theirs.
|
|
4
|
+
*
|
|
5
|
+
* The real resolvers and types are built into a schema; @privilege and @transaction are the framework's, and the context
|
|
6
|
+
* below stands in for what they set up. What each door asks is read from its source.
|
|
7
|
+
*/
|
|
8
|
+
import 'reflect-metadata'
|
|
9
|
+
|
|
10
|
+
const { existsSync, readFileSync } = require('fs')
|
|
11
|
+
const { join } = require('path')
|
|
12
|
+
const { DataSource } = require('typeorm')
|
|
13
|
+
const { buildSchema } = require('type-graphql')
|
|
14
|
+
const { graphql } = require('graphql')
|
|
15
|
+
|
|
16
|
+
const shell = require('@things-factory/shell')
|
|
17
|
+
const authBase = require('@things-factory/auth-base')
|
|
18
|
+
const contact = require('@things-factory/contact')
|
|
19
|
+
const bizBase = require('@things-factory/biz-base')
|
|
20
|
+
|
|
21
|
+
function loadCompiled(modulePath: string): any {
|
|
22
|
+
const resolved = join(__dirname, '..', 'dist-server', `${modulePath}.js`)
|
|
23
|
+
if (!existsSync(resolved)) {
|
|
24
|
+
throw new Error(`organization build output is missing: ${resolved}\nBuild it first: npm run build:server`)
|
|
25
|
+
}
|
|
26
|
+
return require(resolved)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let dataSource: any
|
|
30
|
+
let organization: any
|
|
31
|
+
let schema: any
|
|
32
|
+
let domain: any
|
|
33
|
+
|
|
34
|
+
const ROWS = `
|
|
35
|
+
summary skills updatedAt employeeId
|
|
36
|
+
educations { school major degree startedOn endedOn }
|
|
37
|
+
certificates { name issuer acquiredOn }
|
|
38
|
+
careers { company department position startedOn endedOn duties }
|
|
39
|
+
projects { name client startedOn endedOn role duties technologies }
|
|
40
|
+
`
|
|
41
|
+
const MINE = `query { myEmployeeProfile { ${ROWS} } }`
|
|
42
|
+
const SAVE = `mutation ($profile: EmployeeProfileInput!) { saveMyEmployeeProfile(profile: $profile) { ${ROWS} } }`
|
|
43
|
+
const LIST = `query { employeeProfiles { employeeId name departmentName jobPosition jobResponsibility hiredOn profile { summary skills } } }`
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
organization = loadCompiled('service/index')
|
|
47
|
+
const { resolvers } = loadCompiled('service/employee-profile/index')
|
|
48
|
+
dataSource = new DataSource({
|
|
49
|
+
type: 'sqlite',
|
|
50
|
+
database: ':memory:',
|
|
51
|
+
synchronize: true,
|
|
52
|
+
dropSchema: true,
|
|
53
|
+
namingStrategy: new shell.NamingStrategy(),
|
|
54
|
+
entities: [...shell.entities, ...authBase.entities, ...bizBase.entities, ...contact.entities, ...organization.entities],
|
|
55
|
+
logging: false
|
|
56
|
+
})
|
|
57
|
+
await dataSource.initialize()
|
|
58
|
+
shell.addDataSource('default', dataSource)
|
|
59
|
+
shell.addDataSource('tx', dataSource)
|
|
60
|
+
schema = await buildSchema({ resolvers, validate: false })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
beforeEach(async () => {
|
|
64
|
+
await dataSource.synchronize(true)
|
|
65
|
+
domain = await dataSource.getRepository(shell.Domain).save({ name: 'profile', subdomain: 'profile' })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
afterAll(async () => {
|
|
69
|
+
if (!dataSource) return
|
|
70
|
+
shell.removeDataSource('default')
|
|
71
|
+
shell.removeDataSource('tx')
|
|
72
|
+
await dataSource.destroy()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
async function person(name: string, employee: Record<string, unknown> | null = {}) {
|
|
76
|
+
const user = await dataSource.getRepository(authBase.User).save({ name, email: `${name}@profile.test` })
|
|
77
|
+
if (employee) {
|
|
78
|
+
const department = (await dataSource.getRepository(organization.Department).findOneBy({ name: '개발팀' })) ?? (await dataSource.getRepository(organization.Department).save({ domain, controlNo: 'D-1', name: '개발팀' }))
|
|
79
|
+
await dataSource.getRepository(organization.Employee).save({ domain, controlNo: name, name, user, department, active: true, ...employee })
|
|
80
|
+
}
|
|
81
|
+
return user
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function ask(user: any, source: string, variableValues?: any) {
|
|
85
|
+
const result = await graphql({ schema, source, variableValues, contextValue: { state: { domain, user, tx: dataSource.manager }, t: (key: string) => key } })
|
|
86
|
+
return result
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("an employee's own profile", () => {
|
|
90
|
+
it('★ an account not linked to an employee has none, and cannot write one', async () => {
|
|
91
|
+
const stranger = await person('stranger', null)
|
|
92
|
+
expect((await ask(stranger, MINE)).data.myEmployeeProfile).toBeNull()
|
|
93
|
+
const refused = await ask(stranger, SAVE, { profile: { summary: 'x' } })
|
|
94
|
+
expect(refused.errors?.[0]?.extensions?.requires).toBe('no-employee')
|
|
95
|
+
expect(await dataSource.getRepository(organization.EmployeeProfile).count()).toBe(0)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('★ is written whole and read back; writing again replaces it, one profile per employee', async () => {
|
|
99
|
+
const kim = await person('kim')
|
|
100
|
+
expect((await ask(kim, MINE)).data.myEmployeeProfile).toBeNull()
|
|
101
|
+
|
|
102
|
+
const first = await ask(kim, SAVE, {
|
|
103
|
+
profile: {
|
|
104
|
+
summary: ' MES 10년 ',
|
|
105
|
+
skills: ['TypeScript', 'TypeScript', 'SQL'],
|
|
106
|
+
educations: [{ school: '서울대', major: '컴퓨터공학', degree: '학사', startedOn: '2005-03', endedOn: '2009-02' }],
|
|
107
|
+
projects: [{ name: '미라텍 MES', client: '미라텍', startedOn: '2024-01', role: 'PM' }, { name: '' }]
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
expect(first.errors).toBeUndefined()
|
|
111
|
+
const mine = (await ask(kim, MINE)).data.myEmployeeProfile
|
|
112
|
+
expect(mine).toMatchObject({
|
|
113
|
+
summary: 'MES 10년',
|
|
114
|
+
skills: ['TypeScript', 'SQL'],
|
|
115
|
+
educations: [{ school: '서울대', major: '컴퓨터공학', degree: '학사', startedOn: '2005-03', endedOn: '2009-02' }],
|
|
116
|
+
certificates: [],
|
|
117
|
+
projects: [{ name: '미라텍 MES', client: '미라텍', startedOn: '2024-01', endedOn: null, role: 'PM', duties: null, technologies: null }]
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await ask(kim, SAVE, { profile: { summary: '바꿈', certificates: [{ name: '정보처리기사' }] } })
|
|
121
|
+
const again = (await ask(kim, MINE)).data.myEmployeeProfile
|
|
122
|
+
expect(again).toMatchObject({ summary: '바꿈', skills: [], educations: [], certificates: [{ name: '정보처리기사' }], projects: [] })
|
|
123
|
+
expect(await dataSource.getRepository(organization.EmployeeProfile).count()).toBe(1)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('★ adding from a résumé takes nothing away, and reaches only one\'s own', async () => {
|
|
127
|
+
const kim = await person('kim')
|
|
128
|
+
const park = await person('park')
|
|
129
|
+
await ask(park, SAVE, { profile: { summary: 'park' } })
|
|
130
|
+
await ask(kim, SAVE, { profile: { summary: '옛 요약', skills: ['TypeScript'], projects: [{ name: 'A' }] } })
|
|
131
|
+
const ADD = `mutation ($profile: EmployeeProfileInput!) { addToMyEmployeeProfile(profile: $profile) { ${ROWS} } }`
|
|
132
|
+
const added = await ask(kim, ADD, { profile: { skills: ['typescript', 'PLC'], projects: [{ name: 'A' }, { name: 'B', role: 'PM' }] } })
|
|
133
|
+
expect(added.errors).toBeUndefined()
|
|
134
|
+
expect(added.data.addToMyEmployeeProfile).toMatchObject({ summary: '옛 요약', skills: ['TypeScript', 'PLC'], projects: [{ name: 'A' }, { name: 'B', role: 'PM' }] })
|
|
135
|
+
expect((await ask(park, MINE)).data.myEmployeeProfile.summary).toBe('park')
|
|
136
|
+
const stranger = await person('stranger', null)
|
|
137
|
+
expect((await ask(stranger, ADD, { profile: { summary: 'x' } })).errors?.[0]?.extensions?.requires).toBe('no-employee')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("★ one employee's save never touches another's profile", async () => {
|
|
141
|
+
const kim = await person('kim')
|
|
142
|
+
const park = await person('park')
|
|
143
|
+
await ask(kim, SAVE, { profile: { summary: 'kim' } })
|
|
144
|
+
await ask(park, SAVE, { profile: { summary: 'park' } })
|
|
145
|
+
expect((await ask(kim, MINE)).data.myEmployeeProfile.summary).toBe('kim')
|
|
146
|
+
expect((await ask(park, MINE)).data.myEmployeeProfile.summary).toBe('park')
|
|
147
|
+
expect(await dataSource.getRepository(organization.EmployeeProfile).count()).toBe(2)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('the profiles of every active employee', () => {
|
|
152
|
+
it('★ lists each active employee with the basics and their profile, empty when none was written', async () => {
|
|
153
|
+
const kim = await person('kim', { jobPosition: '과장', jobResponsibility: '팀장', hiredOn: '2020-03-02' })
|
|
154
|
+
await person('lee')
|
|
155
|
+
await person('gone', { retiredOn: '2020-01-01' })
|
|
156
|
+
await person('off', { active: false })
|
|
157
|
+
await ask(kim, SAVE, { profile: { summary: 'S', skills: ['A'] } })
|
|
158
|
+
|
|
159
|
+
const list = await ask(kim, LIST)
|
|
160
|
+
expect(list.errors).toBeUndefined()
|
|
161
|
+
expect(list.data.employeeProfiles.map((one: any) => [one.name, one.departmentName, one.profile?.summary ?? null])).toEqual([
|
|
162
|
+
['kim', '개발팀', 'S'],
|
|
163
|
+
['lee', '개발팀', null]
|
|
164
|
+
])
|
|
165
|
+
expect(list.data.employeeProfiles[0]).toMatchObject({ jobPosition: '과장', jobResponsibility: '팀장', hiredOn: '2020-03-02', profile: { skills: ['A'] } })
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('★ the document of the chosen: active ones only, in the order chosen, as an Excel workbook', async () => {
|
|
169
|
+
const kim = await person('kim')
|
|
170
|
+
await person('lee')
|
|
171
|
+
await person('gone', { retiredOn: '2020-01-01' })
|
|
172
|
+
const ids = (await ask(kim, LIST)).data.employeeProfiles.map((one: any) => one.employeeId)
|
|
173
|
+
const goneId = (await dataSource.getRepository(organization.Employee).findOneBy({ name: 'gone' })).id
|
|
174
|
+
const answer = await ask(kim, `query ($ids: [String!]!) { employeeProfileDocument(employeeIds: $ids, format: XLSX) { fileName mimeType content } }`, { ids: [ids[1], goneId, ids[0]] })
|
|
175
|
+
expect(answer.errors).toBeUndefined()
|
|
176
|
+
const file = answer.data.employeeProfileDocument
|
|
177
|
+
expect(file.fileName).toMatch(/^text\.employee-profile-file-\d{8}\.xlsx$/)
|
|
178
|
+
expect(file.mimeType).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
|
179
|
+
const { Workbook } = require('exceljs')
|
|
180
|
+
const book = new Workbook()
|
|
181
|
+
await book.xlsx.load(Buffer.from(file.content, 'base64'))
|
|
182
|
+
expect(book.worksheets.map((sheet: any) => sheet.name)).toEqual(['Text.employee-profile-doc-roste', 'lee', 'kim'])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('the preview is an HTML page of the same people', async () => {
|
|
186
|
+
const kim = await person('kim')
|
|
187
|
+
const ids = (await ask(kim, LIST)).data.employeeProfiles.map((one: any) => one.employeeId)
|
|
188
|
+
const answer = await ask(kim, `query ($ids: [String!]!) { employeeProfileDocument(employeeIds: $ids, format: HTML) { fileName mimeType content } }`, { ids })
|
|
189
|
+
expect(answer.errors).toBeUndefined()
|
|
190
|
+
const file = answer.data.employeeProfileDocument
|
|
191
|
+
expect(file.mimeType).toBe('text/html')
|
|
192
|
+
expect(file.fileName).toMatch(/\.html$/)
|
|
193
|
+
expect(Buffer.from(file.content, 'base64').toString('utf8')).toContain('<td class="name" colspan="6">kim</td>')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it("★ the document's own words follow the language asked for - one the application runs - or the reader's", () => {
|
|
197
|
+
const { documentTranslator } = loadCompiled('service/employee-profile/employee-profile-query')
|
|
198
|
+
const env = require('@things-factory/env')
|
|
199
|
+
const runs = jest.spyOn(env.config, 'get').mockImplementation((key: string, fallback: any) => (key === 'i18n/languages' ? [{ code: 'ko-KR' }, { code: 'en-US' }] : fallback))
|
|
200
|
+
try {
|
|
201
|
+
const context = { t: (key: string) => `reader:${key}`, i18next: { getFixedT: (lng: string) => (key: string) => `${lng}:${key}` } }
|
|
202
|
+
expect(documentTranslator(context, null)('text.employee-profile-doc-title')).toBe('reader:text.employee-profile-doc-title')
|
|
203
|
+
expect(documentTranslator(context, 'en-US')('text.employee-profile-doc-title')).toBe('en-US:text.employee-profile-doc-title')
|
|
204
|
+
expect(() => documentTranslator(context, 'fr-FR')).toThrow('does not run the language "fr-FR"')
|
|
205
|
+
} finally {
|
|
206
|
+
runs.mockRestore()
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('★ reaching a profile does not reach the employee record behind it', () => {
|
|
211
|
+
const type = schema.getType('EmployeeProfile').getFields()
|
|
212
|
+
expect(Object.keys(type)).not.toContain('employee')
|
|
213
|
+
expect(Object.keys(type)).not.toContain('creator')
|
|
214
|
+
expect(Object.keys(schema.getType('EmployeeWithProfile').getFields()).sort()).toEqual(
|
|
215
|
+
['controlNo', 'departmentName', 'employeeId', 'hiredOn', 'jobPosition', 'jobResponsibility', 'name', 'profile'].sort()
|
|
216
|
+
)
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe('what each door asks', () => {
|
|
221
|
+
const source = (file: string) => readFileSync(join(__dirname, '..', 'server', 'service', 'employee-profile', file), 'utf8')
|
|
222
|
+
const asks = (text: string, door: string) => {
|
|
223
|
+
const before = text.slice(0, text.indexOf(`async ${door}(`))
|
|
224
|
+
return before.slice(before.lastIndexOf('@Directive(\'@privilege')).match(/category: "([a-z-]+)", privilege: "(\w+)", domainOwnerGranted: (\w+)/)?.slice(1)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
it('★ one\'s own under employee-self, everyone\'s under employee-profile - and no door writes another\'s', () => {
|
|
228
|
+
expect(asks(source('employee-profile-query.ts'), 'myEmployeeProfile')).toEqual(['employee-self', 'query', 'true'])
|
|
229
|
+
expect(asks(source('employee-profile-query.ts'), 'employeeProfiles')).toEqual(['employee-profile', 'query', 'true'])
|
|
230
|
+
const mutation = source('employee-profile-mutation.ts')
|
|
231
|
+
/* Two doors write - saving the whole and adding from a résumé - and each finds the profile from the caller alone. */
|
|
232
|
+
expect([...mutation.matchAll(/@Mutation\(/g)]).toHaveLength(2)
|
|
233
|
+
for (const door of ['saveMyEmployeeProfile', 'addToMyEmployeeProfile']) {
|
|
234
|
+
expect(asks(mutation, door)).toEqual(['employee-self', 'mutation', 'true'])
|
|
235
|
+
const body = mutation.slice(mutation.indexOf(`async ${door}(`)).split('\n }')[0]
|
|
236
|
+
expect(body).toMatch(/requireMyEmployee\(context\)/)
|
|
237
|
+
expect(body).not.toMatch(/@Arg\('(employee|employeeId|user|userId)'/)
|
|
238
|
+
}
|
|
239
|
+
expect([...mutation.matchAll(/@Directive\('@transaction'\)\n @Directive\('@privilege\(category: "employee-self", privilege: "mutation"/g)]).toHaveLength(2)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('★ the tasks grant them: own-profile to write one\'s own, profiles-reading to read everyone\'s', () => {
|
|
243
|
+
const { ROLE_TASKS } = loadCompiled('role-tasks')
|
|
244
|
+
expect(ROLE_TASKS.find((task: any) => task.id === 'organization.own-profile').grants).toEqual([{ category: 'employee-self', axes: ['query', 'mutation'] }])
|
|
245
|
+
expect(ROLE_TASKS.find((task: any) => task.id === 'organization.profiles-reading').grants).toEqual([{ category: 'employee-profile', axes: ['query'] }])
|
|
246
|
+
})
|
|
247
|
+
})
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { Workbook } from 'exceljs'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
careerLengthOf,
|
|
5
|
+
careerMonths,
|
|
6
|
+
heightOf,
|
|
7
|
+
rosterTaglineOf,
|
|
8
|
+
taglineOf,
|
|
9
|
+
dotted,
|
|
10
|
+
layoutHtml,
|
|
11
|
+
periodOf,
|
|
12
|
+
profileFileName,
|
|
13
|
+
profileFooterHtml,
|
|
14
|
+
profileHtml,
|
|
15
|
+
profilePreviewHtml,
|
|
16
|
+
profileSheetLayout,
|
|
17
|
+
profileWorkbook,
|
|
18
|
+
profileWordsOf,
|
|
19
|
+
lead,
|
|
20
|
+
rosterLayout,
|
|
21
|
+
sheetNamesOf,
|
|
22
|
+
type ProfileSheetWords
|
|
23
|
+
} from '../server/service/employee-profile/employee-profile-document'
|
|
24
|
+
|
|
25
|
+
/** The profiles a quotation attaches - one layout, painted as Excel and as HTML for PDF (ADR-0169 decision 5). */
|
|
26
|
+
const COUNTS: Record<string, (...args: number[]) => string> = {
|
|
27
|
+
years: (years, months) => `${years}y${months}m`,
|
|
28
|
+
projectCount: n => `${n}p`,
|
|
29
|
+
certificateCount: n => `${n}c`,
|
|
30
|
+
peopleCount: n => `${n}people`
|
|
31
|
+
}
|
|
32
|
+
const WORDS = new Proxy({} as any, { get: (_target, key) => COUNTS[String(key)] ?? `<${String(key)}>` }) as ProfileSheetWords
|
|
33
|
+
|
|
34
|
+
const TODAY = new Date('2026-10-28T00:00:00Z')
|
|
35
|
+
|
|
36
|
+
const KIM = {
|
|
37
|
+
name: '김하늘',
|
|
38
|
+
departmentName: '개발팀',
|
|
39
|
+
jobPosition: '과장',
|
|
40
|
+
jobResponsibility: '팀장',
|
|
41
|
+
hiredOn: '2020-03-02',
|
|
42
|
+
profile: {
|
|
43
|
+
summary: 'MES 10년',
|
|
44
|
+
skills: ['TypeScript', 'PostgreSQL'],
|
|
45
|
+
educations: [{ school: '서울대', major: '컴퓨터공학', degree: '학사', startedOn: '2005-03', endedOn: '2009-02' }],
|
|
46
|
+
certificates: [{ name: '정보처리기사', issuer: '한국산업인력공단', acquiredOn: '2010-05-01' }],
|
|
47
|
+
careers: [{ company: 'A사', department: '연구소', position: '대리', startedOn: '2009-03', endedOn: '2019-12', duties: 'ERP' }],
|
|
48
|
+
projects: [{ name: '미라텍 MES', client: '미라텍', startedOn: '2024-01', role: 'PM', duties: '설계', technologies: 'Node' }]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const valuesOf = (line: { cells: { value: string }[] }) => line.cells.map(cell => cell.value)
|
|
53
|
+
|
|
54
|
+
describe('one layout of a staffing profile', () => {
|
|
55
|
+
const cellsOf = (lines: ReturnType<typeof profileSheetLayout>, role: string) => lines.flatMap(line => line.cells.filter(cell => cell.role === role).map(cell => cell.value))
|
|
56
|
+
|
|
57
|
+
it('★ the name large with where the person stands, then the figures a buyer weighs', () => {
|
|
58
|
+
const lines = profileSheetLayout(KIM, WORDS, '(주)하티오랩', TODAY)
|
|
59
|
+
expect(valuesOf(lines[0])).toEqual(['(주)하티오랩', '<title>'])
|
|
60
|
+
expect(lines[1].cells).toEqual([{ col: 1, span: 6, value: '김하늘', role: 'name' }])
|
|
61
|
+
expect(valuesOf(lines[2])).toEqual(['개발팀 · 과장 / 팀장 · <hiredOn> 2020.03.02'])
|
|
62
|
+
expect(lines[3].cells[0].role).toBe('rule')
|
|
63
|
+
expect(cellsOf(lines, 'statLabel')).toEqual(['<careerLength>', '<projectsDone>', '<certificatesHeld>'])
|
|
64
|
+
expect(cellsOf(lines, 'statValue')).toEqual([careerLengthOf(KIM, WORDS, TODAY), '1p', '1c'])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('★ the expertise, then the projects first - careers, certificates and education after; no line to sign', () => {
|
|
68
|
+
const lines = profileSheetLayout(KIM, WORDS, '(주)하티오랩', TODAY)
|
|
69
|
+
expect(cellsOf(lines, 'section')).toEqual(['<expertise>', '<projects>', '<careers>', '<certificates>', '<educations>'])
|
|
70
|
+
expect(cellsOf(lines, 'summary')).toEqual(['MES 10년'])
|
|
71
|
+
expect(cellsOf(lines, 'skills')).toEqual(['TypeScript, PostgreSQL'])
|
|
72
|
+
const rowAfter = (section: string) => valuesOf(lines[lines.findIndex(line => line.cells[0]?.value === section && line.cells[0].role === 'section') + 3])
|
|
73
|
+
expect(rowAfter('<projects>')).toEqual(['2024.01 ~ <ongoing>', '미라텍 MES', '미라텍', 'PM', '설계', 'Node'])
|
|
74
|
+
expect(rowAfter('<careers>')).toEqual(['2009.03 ~ 2019.12', 'A사', '연구소', '대리', 'ERP'])
|
|
75
|
+
expect(rowAfter('<certificates>')).toEqual(['2010.05.01', '정보처리기사', '한국산업인력공단'])
|
|
76
|
+
expect(rowAfter('<educations>')).toEqual(['2005.03 ~ 2009.02', '서울대', '컴퓨터공학', '학사'])
|
|
77
|
+
expect(JSON.stringify(lines)).not.toMatch(/affirmation|signature/)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('every other row of a table is shaded', () => {
|
|
81
|
+
const two = { ...KIM, profile: { ...KIM.profile, projects: [KIM.profile.projects[0], KIM.profile.projects[0], KIM.profile.projects[0]] } }
|
|
82
|
+
const lines = profileSheetLayout(two, WORDS, '', TODAY)
|
|
83
|
+
const rows = lines.filter(line => line.cells[0]?.role === 'value' && line.cells.length === 6)
|
|
84
|
+
expect(rows.map(line => !!line.zebra)).toEqual([false, true, false])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('a person with nothing written: dashes for the figures, no summary box, every table says so', () => {
|
|
88
|
+
const lines = profileSheetLayout({ name: '박지민', profile: null }, WORDS, '', TODAY)
|
|
89
|
+
expect(cellsOf(lines, 'statValue')).toEqual(['—', '—', '—'])
|
|
90
|
+
expect(cellsOf(lines, 'summary')).toEqual([])
|
|
91
|
+
expect(cellsOf(lines, 'none')).toEqual(['<none>', '<none>', '<none>', '<none>'])
|
|
92
|
+
expect(taglineOf({ name: '박지민' }, WORDS)).toBe('')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('★ the career counts the months worked once - earlier jobs and the time here, overlaps not twice', () => {
|
|
96
|
+
expect(careerMonths({ hiredOn: '2026-01-15' }, TODAY)).toBe(10)
|
|
97
|
+
expect(careerMonths({ hiredOn: '2025-10-01', profile: { careers: [{ startedOn: '2025-01', endedOn: '2025-12' }] } }, TODAY)).toBe(22)
|
|
98
|
+
expect(careerMonths({ profile: { careers: [{ startedOn: '2024-01' }] } }, TODAY)).toBe(34)
|
|
99
|
+
expect(careerMonths({ profile: { careers: [{ endedOn: '2020-01' }] } }, TODAY)).toBe(0)
|
|
100
|
+
expect(careerLengthOf(KIM, WORDS, TODAY)).toBe(`${Math.floor(careerMonths(KIM, TODAY) / 12)}y${careerMonths(KIM, TODAY) % 12}m`)
|
|
101
|
+
expect(careerLengthOf({}, WORDS, TODAY)).toBe('')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('periods and days in the dotted form, an open end still going on', () => {
|
|
105
|
+
expect(dotted('2023-03')).toBe('2023.03')
|
|
106
|
+
expect(periodOf('2024-01', '', 'now')).toBe('2024.01 ~ now')
|
|
107
|
+
expect(periodOf('', '2024-01', 'now')).toBe('~ 2024.01')
|
|
108
|
+
expect(periodOf(undefined, undefined, 'now')).toBe('')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('a roster: how many and their experience on average, then one line a person in the order picked', () => {
|
|
112
|
+
const lines = rosterLayout([KIM, { name: '박지민', hiredOn: '2025-10-01' }], WORDS, 'C', TODAY)
|
|
113
|
+
expect(valuesOf(lines[0])).toEqual(['C', '<writtenOn> 2026.10.28'])
|
|
114
|
+
expect(valuesOf(lines[1])).toEqual(['<rosterTitle>'])
|
|
115
|
+
const average = Math.round((careerMonths(KIM, TODAY) + 13) / 2)
|
|
116
|
+
expect(valuesOf(lines[2])).toEqual([`2people · <averageCareer> ${Math.floor(average / 12)}y${average % 12}m`])
|
|
117
|
+
expect(valuesOf(lines[5])).toEqual(['<number>', '<name>', '<department>', '<jobPosition>', '<careerLength>', '<skills>'])
|
|
118
|
+
expect(lines.slice(6).map(line => valuesOf(line).slice(0, 2))).toEqual([['1', '김하늘'], ['2', '박지민']])
|
|
119
|
+
expect(rosterTaglineOf([], WORDS, TODAY)).toBe('0people')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('painted as Excel', () => {
|
|
124
|
+
it('★ a ruled line is as tall as its longest cell wraps - Excel does not grow it for written text', () => {
|
|
125
|
+
const one = heightOf({ cells: [{ col: 1, value: '2012.03 ~ 2019.02', role: 'value' }, { col: 2, span: 5, value: '짧음', role: 'value' }] })!
|
|
126
|
+
const wrapped = heightOf({ cells: [{ col: 5, value: '요구 분석, 설계, 일정·인력 관리와 고객 보고', role: 'value' }] })!
|
|
127
|
+
expect(one).toBe(22)
|
|
128
|
+
expect(wrapped).toBeGreaterThan(one)
|
|
129
|
+
expect(heightOf({ cells: [{ col: 1, span: 6, value: 'x', role: 'name' }] })).toBeUndefined()
|
|
130
|
+
expect(heightOf({ cells: [{ col: 1, value: 'a\nb\nc', role: 'value' }] })).toBe(52)
|
|
131
|
+
expect(heightOf({ cells: [{ col: 1, span: 6, value: '짧은 소개', role: 'summary' }] })).toBe(30)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
async function readBack(people: any[]) {
|
|
135
|
+
const buffer = await profileWorkbook(people, WORDS, '(주)하티오랩', TODAY).xlsx.writeBuffer()
|
|
136
|
+
const book = new Workbook()
|
|
137
|
+
await book.xlsx.load(buffer as any)
|
|
138
|
+
return book
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
it('★ several people open with the roster, then a sheet a person in the order picked - read back from the file', async () => {
|
|
142
|
+
const book = await readBack([KIM, { name: '박지민' }])
|
|
143
|
+
expect(book.worksheets.map(sheet => sheet.name)).toEqual(['<rosterTitle>', '김하늘', '박지민'])
|
|
144
|
+
expect((await readBack([KIM])).worksheets.map(sheet => sheet.name)).toEqual(['김하늘'])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('★ the title spans the page, labels and heads are shaded and ruled, values ruled and wrapped, A4 to one page wide', async () => {
|
|
148
|
+
const sheet = (await readBack([KIM])).getWorksheet('김하늘')!
|
|
149
|
+
expect(sheet.getCell('A2').value).toBe('김하늘')
|
|
150
|
+
expect(sheet.getCell('A2').font).toMatchObject({ bold: true, size: 22, color: { argb: 'FF1F3A5F' } })
|
|
151
|
+
expect(sheet.getCell('F2').isMerged).toBe(true)
|
|
152
|
+
expect(sheet.getCell('A4').border?.bottom?.style).toBe('thick')
|
|
153
|
+
const rows = sheet.getRows(1, sheet.rowCount)!
|
|
154
|
+
const head = rows.find(row => row.getCell(1).value === '<period>')!
|
|
155
|
+
expect((head.getCell(1).fill as any)?.fgColor?.argb).toBe('FF1F3A5F')
|
|
156
|
+
expect(head.getCell(1).font?.color?.argb).toBe('FFFFFFFF')
|
|
157
|
+
const value = sheet.getRow(head.number + 1).getCell(2)
|
|
158
|
+
expect(value.value).toBe('미라텍 MES')
|
|
159
|
+
expect(value.border?.right?.style).toBe('thin')
|
|
160
|
+
expect(value.alignment).toMatchObject({ wrapText: true, indent: 1 })
|
|
161
|
+
expect(sheet.headerFooter.oddFooter).toBe('&L&8(주)하티오랩 · <title>&R&8&P / &N')
|
|
162
|
+
expect(sheet.pageSetup).toMatchObject({ paperSize: 9, orientation: 'portrait', fitToWidth: 1, fitToHeight: 0 })
|
|
163
|
+
expect(sheet.views[0]).toMatchObject({ showGridLines: false })
|
|
164
|
+
expect(sheet.getColumn(1).width).toBe(18)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('★ sheet names Excel takes: at most 31 characters, none of \\ / ? * [ ] :, no two alike, none the roster has', () => {
|
|
168
|
+
const names = sheetNamesOf(['홍길동', '홍길동', 'HONG', 'hong', 'a/b:c*d?[e]', '', null, 'x'.repeat(40), 'x'.repeat(40), '인력 현황'], ['인력 현황'])
|
|
169
|
+
expect(names.slice(0, 7)).toEqual(['홍길동', '홍길동 (2)', 'HONG', 'hong (2)', 'a b c d e', '#6', '#7'])
|
|
170
|
+
expect(names[7]).toBe('x'.repeat(31))
|
|
171
|
+
expect(names[8]).toBe('x'.repeat(27) + ' (2)')
|
|
172
|
+
expect(names[9]).toBe('인력 현황 (2)')
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('painted as HTML for the PDF', () => {
|
|
177
|
+
it('★ a page a person after the roster, the same cells, spans as colspan, text escaped', () => {
|
|
178
|
+
const html = profileHtml([KIM, { name: '<b>박</b>' }], WORDS, 'C', TODAY)
|
|
179
|
+
expect(html.match(/<section class="sheet">/g)).toHaveLength(3)
|
|
180
|
+
expect(html).toContain('<td class="name" colspan="6">김하늘</td>')
|
|
181
|
+
expect(html).toContain('<span class="tag">TypeScript</span><span class="tag">PostgreSQL</span>')
|
|
182
|
+
expect(html).toMatch(/<tr class="zebra"|<tr style/)
|
|
183
|
+
expect(html).toContain('<b>박</b>')
|
|
184
|
+
expect(html).not.toContain('<b>박</b>')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('a line keeps its line breaks, an empty line is a gap, a cell left of the first one is filled', () => {
|
|
188
|
+
expect(layoutHtml([{ cells: [{ col: 2, value: 'a\nb', role: 'value' }] }])).toContain('<td colspan="1"></td><td class="value">a<br>b</td><td colspan="4"></td>')
|
|
189
|
+
expect(layoutHtml([{ cells: [] }])).toContain('<tr class="gap">')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('the foot of every PDF page: the employer and the page of how many, a brace in the name opening no template', () => {
|
|
193
|
+
const footer = profileFooterHtml('{{evil}} & Co', WORDS)
|
|
194
|
+
expect(footer).toContain('{{evil}} & Co · <title>')
|
|
195
|
+
expect(footer).toContain('<span class="pageNumber"></span> / <span class="totalPages"></span>')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('★ the preview is the same pages as one document, shown as sheets of A4 on a screen', () => {
|
|
199
|
+
const page = profilePreviewHtml([KIM], WORDS, 'C', TODAY)
|
|
200
|
+
expect(page.startsWith('<!doctype html>')).toBe(true)
|
|
201
|
+
expect(page).toContain(profileHtml([KIM], WORDS, 'C', TODAY))
|
|
202
|
+
expect(page).toMatch(/@media screen \{[\s\S]*\.sheet \{[^}]*width: 210mm/)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('the words head as a document heads - a capital first where the language has one', () => {
|
|
206
|
+
expect(lead('proyectos')).toBe('Proyectos')
|
|
207
|
+
expect(lead('프로젝트 이력')).toBe('프로젝트 이력')
|
|
208
|
+
expect(lead('')).toBe('')
|
|
209
|
+
const words = profileWordsOf(key => (key === 'text.employee-profile-doc-years' ? '{years} años' : 'habilidades'))
|
|
210
|
+
expect(words.skills).toBe('Habilidades')
|
|
211
|
+
expect(words.years(2, 0)).toBe('{years} años')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('the file is named by the word and the day', () => {
|
|
215
|
+
expect(profileFileName('인력프로필', 'xlsx', new Date(2026, 9, 7))).toBe('인력프로필-20261007.xlsx')
|
|
216
|
+
expect(profileFileName('인력프로필', 'pdf', new Date(2026, 9, 7))).toBe('인력프로필-20261007.pdf')
|
|
217
|
+
expect(profileFileName('인력프로필', 'html', new Date(2026, 9, 7))).toBe('인력프로필-20261007.html')
|
|
218
|
+
})
|
|
219
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The person's own profile as the assistant's 입력안 (ADR-0169 decision 10): a résumé read into rows of sections, each
|
|
3
|
+
* section added to one's own profile by one call of addToMyEmployeeProfile, nothing kept taken away.
|
|
4
|
+
*/
|
|
5
|
+
import { clearEntryTargets, declareEntryTarget } from '../../ai-assistant/server/service/data-entry/entry-target'
|
|
6
|
+
import { profileAdditionOf, profileEntryIssues, PROFILE_ENTRY_FIELDS } from '../server/ai-entry/employee-profile-entry'
|
|
7
|
+
import { employeeProfileEntryTarget, ORGANIZATION_EMPLOYEE_PROFILE } from '../server/ai-entry/organization-entry-targets'
|
|
8
|
+
import { addedProfileOf } from '../server/service/employee-profile/employee-profile-rules'
|
|
9
|
+
|
|
10
|
+
import en from '../translations/en.json'
|
|
11
|
+
import es from '../translations/es.json'
|
|
12
|
+
import ko from '../translations/ko.json'
|
|
13
|
+
|
|
14
|
+
const row = (values: Record<string, unknown>, index = 0) => ({ index, values, sources: [], issues: [] })
|
|
15
|
+
|
|
16
|
+
describe("the assistant's 입력안 of one's own profile", () => {
|
|
17
|
+
afterEach(() => clearEntryTargets())
|
|
18
|
+
|
|
19
|
+
it('★ is accepted by the assistant as declared - one call of addToMyEmployeeProfile a section', () => {
|
|
20
|
+
const target = employeeProfileEntryTarget()
|
|
21
|
+
expect(() => declareEntryTarget(target as any)).not.toThrow()
|
|
22
|
+
expect(target.key).toBe(ORGANIZATION_EMPLOYEE_PROFILE)
|
|
23
|
+
expect(target.mutation).toMatch(/addToMyEmployeeProfile\(profile: \$profile\)/)
|
|
24
|
+
expect(target.group?.by).toBe('section')
|
|
25
|
+
expect(target.description).toMatch(/OWN/)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('★ the rows of a section become what the door adds - only the section fields', () => {
|
|
29
|
+
expect(profileAdditionOf([row({ section: 'summary', text: ' MES 12년 ' })])).toEqual({ summary: 'MES 12년' })
|
|
30
|
+
expect(profileAdditionOf([row({ section: 'skill', text: 'TypeScript' }), row({ section: 'skill', text: 'PLC' }, 1)])).toEqual({ skills: ['TypeScript', 'PLC'] })
|
|
31
|
+
expect(profileAdditionOf([row({ section: 'project', name: '미라텍 MES', client: '미라텍', startedOn: '2024-01', role: 'PM', school: '섞임' })])).toEqual({
|
|
32
|
+
projects: [{ name: '미라텍 MES', client: '미라텍', startedOn: '2024-01', endedOn: '', role: 'PM', duties: '', technologies: '' }]
|
|
33
|
+
})
|
|
34
|
+
expect(profileAdditionOf([row({ section: 'education', school: '한양대', degree: '학사' })])).toEqual({
|
|
35
|
+
educations: [{ school: '한양대', major: '', degree: '학사', startedOn: '', endedOn: '' }]
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('a row without what its section needs is kept out, and so is a second summary', () => {
|
|
40
|
+
const issues = profileEntryIssues([
|
|
41
|
+
row({ section: 'project', client: '미라텍' }),
|
|
42
|
+
row({ section: 'certificate', name: 'PMP' }, 1),
|
|
43
|
+
row({ section: 'summary', text: 'a' }, 2),
|
|
44
|
+
row({ section: 'summary', text: 'b' }, 3)
|
|
45
|
+
])
|
|
46
|
+
expect(issues[0]).toEqual([{ field: 'name', code: 'required', blocking: true }])
|
|
47
|
+
expect(issues[1]).toEqual([])
|
|
48
|
+
expect(issues[2]).toEqual([])
|
|
49
|
+
expect(issues[3].map(issue => issue.code)).toEqual(['one-summary'])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('★ adding takes nothing away: a summary given replaces, skills and rows are added unless already there', () => {
|
|
53
|
+
const held = { summary: '옛 요약', skills: ['TypeScript'], projects: [{ name: 'A', client: 'X' }], educations: [], certificates: [], careers: [] }
|
|
54
|
+
const added = addedProfileOf(held, { summary: '새 요약', skills: ['typescript', 'PLC'], projects: [{ name: ' a ', client: 'x' }, { name: 'B' }] })
|
|
55
|
+
expect(added).toEqual({ summary: '새 요약', skills: ['TypeScript', 'PLC'], educations: [], certificates: [], careers: [], projects: [{ name: 'A', client: 'X' }, { name: 'B' }] })
|
|
56
|
+
expect(addedProfileOf(held, { skills: ['Go'] }).summary).toBe('옛 요약')
|
|
57
|
+
expect(addedProfileOf(null, { certificates: [{ name: 'PMP' }] }).certificates).toEqual([{ name: 'PMP' }])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('every word the card shows is in the languages the application runs', () => {
|
|
61
|
+
const keys = [
|
|
62
|
+
employeeProfileEntryTarget().labelKey,
|
|
63
|
+
...PROFILE_ENTRY_FIELDS.map(field => field.labelKey!),
|
|
64
|
+
...PROFILE_ENTRY_FIELDS.find(field => field.name === 'section')!.values!.map(value => `text.organization-entry-profile-section-${value}`),
|
|
65
|
+
'text.organization-entry-profile-one-summary'
|
|
66
|
+
]
|
|
67
|
+
for (const [language, words] of Object.entries({ ko, en, es } as Record<string, Record<string, string>>)) {
|
|
68
|
+
const missing = keys.filter(key => !(key in words) && !['label.department'].includes(key))
|
|
69
|
+
expect([language, missing]).toEqual([language, []])
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
})
|