@things-factory/figure-service 10.1.27 → 10.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/service/figure/figure-gate.d.ts +4 -1
- package/dist-server/service/figure/figure-gate.js +40 -1
- package/dist-server/service/figure/figure-gate.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.d.ts +7 -0
- package/dist-server/service/figure/figure-mutation.js +43 -7
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-propose.js +1 -1
- package/dist-server/service/figure/figure-propose.js.map +1 -1
- package/dist-server/service/figure/figure-query.js +11 -1
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-rename.d.ts +69 -0
- package/dist-server/service/figure/figure-rename.js +69 -0
- package/dist-server/service/figure/figure-rename.js.map +1 -0
- package/dist-server/service/figure/figure-tools.js +15 -2
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/service/figure/figure-type.d.ts +8 -2
- package/dist-server/service/figure/figure-type.js +12 -3
- package/dist-server/service/figure/figure-type.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/service/figure/figure-gate.ts +43 -3
- package/server/service/figure/figure-mutation.ts +58 -8
- package/server/service/figure/figure-propose.ts +1 -1
- package/server/service/figure/figure-query.ts +12 -1
- package/server/service/figure/figure-rename-db.test.ts +138 -0
- package/server/service/figure/figure-rename.test.ts +99 -0
- package/server/service/figure/figure-rename.ts +98 -0
- package/server/service/figure/figure-tool-doors.test.ts +143 -0
- package/server/service/figure/figure-tools.test.ts +6 -1
- package/server/service/figure/figure-tools.ts +15 -2
- package/server/service/figure/figure-type.ts +11 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-service",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.29",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -23,15 +23,15 @@
|
|
|
23
23
|
"migration:create": "node ../../node_modules/typeorm/cli.js migration:create ./server/migrations/migration"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@hatiolab/figure-model": "^0.1.
|
|
26
|
+
"@hatiolab/figure-model": "^0.1.27",
|
|
27
27
|
"@operato/typeorm-history": "^10.0.0",
|
|
28
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
29
|
-
"@things-factory/auth-base": "^10.1.
|
|
28
|
+
"@things-factory/ai-client-base": "^10.1.29",
|
|
29
|
+
"@things-factory/auth-base": "^10.1.29",
|
|
30
30
|
"@things-factory/env": "^10.1.20",
|
|
31
|
-
"@things-factory/shell": "^10.1.
|
|
31
|
+
"@things-factory/shell": "^10.1.29",
|
|
32
32
|
"graphql-scalars": "^1.22.4",
|
|
33
33
|
"graphql-upload": "^15.0.2",
|
|
34
34
|
"koa-router": "^7.4.0"
|
|
35
35
|
},
|
|
36
|
-
"gitHead": "
|
|
36
|
+
"gitHead": "e6bf9dec003fe5e794746aaeb5852b3b50478232"
|
|
37
37
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { blocksRelease, compile, sizingReport, validate } from '@hatiolab/figure-model'
|
|
1
|
+
import { blocksRelease, compile, contractChanges, sizingReport, validate } from '@hatiolab/figure-model'
|
|
2
2
|
import type { Axis, FigureSource } from '@hatiolab/figure-model'
|
|
3
3
|
|
|
4
4
|
/** Service transport shape. It deliberately contains only an explicit, reversible FigureSource edit. */
|
|
@@ -127,8 +127,11 @@ function measuredFixes(source: FigureSource, factor: number): GateFix[] {
|
|
|
127
127
|
*
|
|
128
128
|
* 정본이 없거나(아직 저장 전) JSON 이 아니면 **막는다** — 발행할 대상이 없는 것이다. 그 경우를
|
|
129
129
|
* 조용히 통과시키면 빈 것이 발행된다.
|
|
130
|
+
*
|
|
131
|
+
* `releasedJson` 은 **직전 발행판의 정본**이다. 주면 그것과 견주어 「이미 놓인 인스턴스를 깨는
|
|
132
|
+
* 발행인가」를 함께 낸다(ADR-0063 결정 3). 한 번도 발행된 적 없으면 견줄 것이 없으므로 안 준다.
|
|
130
133
|
*/
|
|
131
|
-
export function inspectSource(sourceJson?: string | null): Inspection {
|
|
134
|
+
export function inspectSource(sourceJson?: string | null, releasedJson?: string | null): Inspection {
|
|
132
135
|
if (!sourceJson) {
|
|
133
136
|
return {
|
|
134
137
|
blocked: true,
|
|
@@ -187,11 +190,48 @@ export function inspectSource(sourceJson?: string | null): Inspection {
|
|
|
187
190
|
how: finding.how,
|
|
188
191
|
/* 숫자가 아니라 자리를 말한다 — 어느 부품들이 갈라져 나왔나. */
|
|
189
192
|
at: finding.clumps.map(clump => [...clump].sort().join('+')).join(' | ') || undefined,
|
|
190
|
-
|
|
193
|
+
fixes: finding.code === 'sizing-splits' ? fixes.filter(fix => fix.findingWay === finding.way) : undefined,
|
|
191
194
|
blocking: finding.code === 'sizing-splits'
|
|
192
195
|
})
|
|
193
196
|
}
|
|
197
|
+
|
|
198
|
+
findings.push(...contractFindings(source, releasedJson))
|
|
194
199
|
}
|
|
195
200
|
|
|
196
201
|
return { findings, blocked: findings.some(finding => finding.blocking) }
|
|
197
202
|
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 직전 발행판과 견준 것 — **약속을 깨는 발행인가**(ADR-0063 결정 3).
|
|
206
|
+
*
|
|
207
|
+
* 무엇이 달라졌고 그것이 막는 것인지는 형식이 정한다(`contractChanges`). 여기서는 화면과 발행 문이
|
|
208
|
+
* 읽는 모양으로 옮기기만 한다.
|
|
209
|
+
*
|
|
210
|
+
* **앞 판을 오늘 형식으로 못 읽으면 조용히 넘어가지 않는다.** 판 1 은 오늘 형식이 거절하는 판으로
|
|
211
|
+
* 적혀 있을 수 있고, 그때 견주기를 건너뛰면 아무 말 없이 관문이 없는 것이 된다. 막지는 않는다 —
|
|
212
|
+
* 그러면 그 도형은 영영 다시 발행할 수 없다.
|
|
213
|
+
*/
|
|
214
|
+
function contractFindings(source: FigureSource, releasedJson?: string | null): GateFinding[] {
|
|
215
|
+
if (!releasedJson) return []
|
|
216
|
+
|
|
217
|
+
let released: FigureSource
|
|
218
|
+
try {
|
|
219
|
+
released = JSON.parse(releasedJson) as FigureSource
|
|
220
|
+
return contractChanges(released, source).map(change => ({
|
|
221
|
+
code: change.code,
|
|
222
|
+
message: change.message,
|
|
223
|
+
why: change.why,
|
|
224
|
+
at: change.subject,
|
|
225
|
+
blocking: change.blocking
|
|
226
|
+
}))
|
|
227
|
+
} catch (e) {
|
|
228
|
+
return [
|
|
229
|
+
{
|
|
230
|
+
code: 'released-unreadable',
|
|
231
|
+
message: `직전 발행판을 오늘 형식으로 읽을 수 없어 견주지 못했다: ${(e as Error).message}`,
|
|
232
|
+
why: '이 발행이 이미 놓인 인스턴스를 깨는지는 확인되지 않았다.',
|
|
233
|
+
blocking: false
|
|
234
|
+
}
|
|
235
|
+
]
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -8,6 +8,7 @@ import { FigureHistory } from './figure-history.js'
|
|
|
8
8
|
import { FigurePatch, FigureSaveResult, NewFigure } from './figure-type.js'
|
|
9
9
|
import { FigureProposal, ProposeRequest } from './figure-propose-type.js'
|
|
10
10
|
import { inspectSource } from './figure-gate.js'
|
|
11
|
+
import { renameDraftType, renameFacts } from './figure-rename.js'
|
|
11
12
|
import { parseSource } from './figure-source.js'
|
|
12
13
|
import { ProposeFailure, proposeFigure } from './figure-propose.js'
|
|
13
14
|
|
|
@@ -78,6 +79,23 @@ export class FigureMutation {
|
|
|
78
79
|
violations = parsed.violations
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
/*
|
|
83
|
+
**타입은 발행 전에만 바뀐다** — ADR-0063 결정 4.
|
|
84
|
+
|
|
85
|
+
발행된 뒤에는 이미 배치된 도면이 이 이름을 가리키고, 바꾸면 그 도면이 아무 오류 없이
|
|
86
|
+
빈 자리가 된다. 발행된 적 없는 도형은 가리키는 도면이 있을 수 없다 — 도면은 최신
|
|
87
|
+
발행판을 그리므로 그려질 자리가 없다.
|
|
88
|
+
|
|
89
|
+
행과 정본 둘 다 고친다. 정본 안에도 `type` 이 있어서 한쪽만 고치면 저장된 JSON 과
|
|
90
|
+
행이 다른 이름을 말하고, 발행할 때 어느 쪽이 도면에 적히는지가 갈린다.
|
|
91
|
+
*/
|
|
92
|
+
let renamed: string | undefined
|
|
93
|
+
if (patch.type !== undefined && patch.type !== figure.type) {
|
|
94
|
+
const change = await this.renameType(figure, domain, patch.type, source, tx)
|
|
95
|
+
renamed = change.type
|
|
96
|
+
source = change.source
|
|
97
|
+
}
|
|
98
|
+
|
|
81
99
|
/*
|
|
82
100
|
**판 번호는 여기서 손대지 않는다.** 저장은 저작이고, 판은 발행에서 생긴다(`releaseFigure`).
|
|
83
101
|
board 의 `updateBoard` 도 같다 — 저장 경로에는 version 이 없다.
|
|
@@ -93,6 +111,8 @@ export class FigureMutation {
|
|
|
93
111
|
domain,
|
|
94
112
|
...figure,
|
|
95
113
|
...patch,
|
|
114
|
+
/* 앞뒤 공백을 떼고 겹침까지 본 이름. `patch.type` 을 그대로 두면 다듬기 전 값이 들어간다. */
|
|
115
|
+
...(renamed !== undefined ? { type: renamed } : {}),
|
|
96
116
|
source,
|
|
97
117
|
/*
|
|
98
118
|
**고치면 발행이 풀린다.**
|
|
@@ -114,6 +134,30 @@ export class FigureMutation {
|
|
|
114
134
|
return { figure: saved, violations }
|
|
115
135
|
}
|
|
116
136
|
|
|
137
|
+
/**
|
|
138
|
+
* 초안의 타입을 고친다 — 규칙은 `figure-rename.ts`, 여기서는 그 규칙이 볼 **사실만** 읽는다.
|
|
139
|
+
*
|
|
140
|
+
* 겹침은 DB 인덱스(`ix_figure_1`)도 잡지만 먼저 읽어 본다. 인덱스가 던지는 말은 저작자가
|
|
141
|
+
* 읽을 것이 못 되고, 어느 이름이 이미 쓰였는지도 안 알려 준다.
|
|
142
|
+
*/
|
|
143
|
+
private async renameType(
|
|
144
|
+
figure: Figure,
|
|
145
|
+
domain: { id: string },
|
|
146
|
+
wanted: string,
|
|
147
|
+
source: string,
|
|
148
|
+
tx: { getRepository: <T>(target: new () => T) => any }
|
|
149
|
+
): Promise<{ type: string; source: string }> {
|
|
150
|
+
/* 읽어 온 엔티티에는 관계가 안 실려 있다 — 도메인은 문맥에서 온 것을 쓴다. */
|
|
151
|
+
const facts = await renameFacts(
|
|
152
|
+
{ figures: tx.getRepository(Figure), histories: tx.getRepository(FigureHistory) },
|
|
153
|
+
figure,
|
|
154
|
+
domain,
|
|
155
|
+
wanted
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return renameDraftType({ type: figure.type, source }, wanted, facts)
|
|
159
|
+
}
|
|
160
|
+
|
|
117
161
|
/**
|
|
118
162
|
* 발행한다 — **판이 생기는 유일한 자리.**
|
|
119
163
|
*
|
|
@@ -168,7 +212,19 @@ export class FigureMutation {
|
|
|
168
212
|
|
|
169
213
|
막는 이유는 사유와 함께 낸다. 「발행할 수 없습니다」로 뭉치면 무엇을 고쳐야 하는지 모른다.
|
|
170
214
|
*/
|
|
171
|
-
|
|
215
|
+
/*
|
|
216
|
+
직전 발행판을 먼저 꺼낸다. 판 번호를 세는 데도 쓰고, **약속을 깨는 발행인가**를 견주는 데도
|
|
217
|
+
쓴다(ADR-0063 결정 3) — 도면은 type 만 적고 늘 최신 발행판을 그리므로, 다시 발행하는 것은
|
|
218
|
+
그 type 을 놓아 둔 모든 도면에게 같은 약속을 다시 하는 일이다.
|
|
219
|
+
*/
|
|
220
|
+
const released = await historyRepository
|
|
221
|
+
.createQueryBuilder('history')
|
|
222
|
+
.where('history.originalId = :id', { id: figure.id })
|
|
223
|
+
.orderBy('history.version', 'DESC')
|
|
224
|
+
.limit(1)
|
|
225
|
+
.getOne()
|
|
226
|
+
|
|
227
|
+
const inspection = inspectSource(figure.source, released?.source)
|
|
172
228
|
if (inspection.blocked) {
|
|
173
229
|
const reasons = inspection.findings
|
|
174
230
|
.filter(finding => finding.blocking)
|
|
@@ -177,13 +233,7 @@ export class FigureMutation {
|
|
|
177
233
|
throw new Error(`Figure '${figure.type}' 은 아직 발행할 수 없다 — ${reasons.join(' · ')}`)
|
|
178
234
|
}
|
|
179
235
|
|
|
180
|
-
const
|
|
181
|
-
.createQueryBuilder('history')
|
|
182
|
-
.select('MAX(history.version)', 'max')
|
|
183
|
-
.where('history.originalId = :id', { id: figure.id })
|
|
184
|
-
.getRawOne()
|
|
185
|
-
|
|
186
|
-
const nextVersion = highest?.max ? Number(highest.max) + 1 : 1
|
|
236
|
+
const nextVersion = released?.version ? Number(released.version) + 1 : 1
|
|
187
237
|
|
|
188
238
|
return await repository.save({
|
|
189
239
|
/*
|
|
@@ -188,7 +188,7 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
188
188
|
'Hard limits — exceeding these makes the figure unusable at scale:',
|
|
189
189
|
` parts by detailLevel: ${partLimits}`,
|
|
190
190
|
` material groups (distinct token+preset+slot combinations) <= ${LIMITS.materialGroups}`,
|
|
191
|
-
`
|
|
191
|
+
` independently transformed parts <= ${LIMITS.independentParts}`,
|
|
192
192
|
` transparent materials <= ${LIMITS.transparentMaterials}`,
|
|
193
193
|
'',
|
|
194
194
|
'Geometry rules:',
|
|
@@ -113,7 +113,18 @@ export class FigureQuery {
|
|
|
113
113
|
throw new Error(`Figure ${id} 를 찾을 수 없다`)
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
/*
|
|
117
|
+
**화면이 발행 문과 같은 것을 본다.** 직전 발행판을 여기서도 함께 넘기지 않으면, 저작 화면은
|
|
118
|
+
조용한데 발행을 누른 순간 계약 위반으로 막힌다 — 그때는 이미 열 군데를 고친 뒤다.
|
|
119
|
+
*/
|
|
120
|
+
const released = await getRepository(FigureHistory)
|
|
121
|
+
.createQueryBuilder('history')
|
|
122
|
+
.where('history.originalId = :id', { id })
|
|
123
|
+
.orderBy('history.version', 'DESC')
|
|
124
|
+
.limit(1)
|
|
125
|
+
.getOne()
|
|
126
|
+
|
|
127
|
+
return inspectSource(figure.source, released?.source)
|
|
117
128
|
}
|
|
118
129
|
|
|
119
130
|
@Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 이름을 바꾸기 전에 읽는 사실 셋 — **sqlite 를 실제로 들고** 확인한다.
|
|
3
|
+
*
|
|
4
|
+
* 규칙(`renameDraftType`)은 사실을 받아 판단하므로 DB 없이 시험한다. 그런데 그 사실을 읽는
|
|
5
|
+
* 질의가 틀리면 저작자에게는 저장 실패로만 보인다. 실제로 그랬다 — 판본을 `figure: { id }` 로
|
|
6
|
+
* 물었고 `FigureHistory` 는 관계 칸이 아니라 `originalId` 로 가리킨다. TypeORM 이 「그런 속성이
|
|
7
|
+
* 없다」로 던졌고, 순수 시험은 전부 초록이었다.
|
|
8
|
+
*
|
|
9
|
+
* 그래서 여기서는 질의를 **돌려 본다.**
|
|
10
|
+
*/
|
|
11
|
+
import type { DataSource } from 'typeorm'
|
|
12
|
+
|
|
13
|
+
const { DataSource: TypeOrmDataSource } = require('typeorm')
|
|
14
|
+
const shell = require('@things-factory/shell')
|
|
15
|
+
const authBase = require('@things-factory/auth-base')
|
|
16
|
+
|
|
17
|
+
let dataSource: DataSource | null = null
|
|
18
|
+
let Figure: any
|
|
19
|
+
let FigureHistory: any
|
|
20
|
+
let renameFacts: any
|
|
21
|
+
|
|
22
|
+
function loadCompiled(modulePath: string): any {
|
|
23
|
+
const { existsSync } = require('fs')
|
|
24
|
+
const { join } = require('path')
|
|
25
|
+
const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
|
|
26
|
+
|
|
27
|
+
if (!existsSync(resolved)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`figure-service 산출물이 없다: ${resolved}\n` +
|
|
30
|
+
`이 시험은 tsc 산출물을 든다. 먼저 빌드할 것:\n` +
|
|
31
|
+
` yarn workspace @things-factory/figure-service build:server`
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return require(resolved)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
Figure = loadCompiled('service/figure/figure').Figure
|
|
40
|
+
FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
|
|
41
|
+
renameFacts = loadCompiled('service/figure/figure-rename').renameFacts
|
|
42
|
+
|
|
43
|
+
dataSource = new TypeOrmDataSource({
|
|
44
|
+
type: 'sqlite',
|
|
45
|
+
database: ':memory:',
|
|
46
|
+
synchronize: true,
|
|
47
|
+
dropSchema: true,
|
|
48
|
+
namingStrategy: new shell.NamingStrategy(),
|
|
49
|
+
entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
|
|
50
|
+
logging: false
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
await dataSource.initialize()
|
|
54
|
+
shell.addDataSource('default', dataSource)
|
|
55
|
+
shell.addDataSource('tx', dataSource)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
beforeEach(async () => {
|
|
59
|
+
await dataSource!.synchronize(true)
|
|
60
|
+
await seedDomain()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
afterAll(async () => {
|
|
64
|
+
if (!dataSource) return
|
|
65
|
+
shell.removeDataSource('default')
|
|
66
|
+
shell.removeDataSource('tx')
|
|
67
|
+
await dataSource.destroy()
|
|
68
|
+
dataSource = null
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
let DOMAIN: { id: string }
|
|
72
|
+
|
|
73
|
+
/* 도형이 도메인을 가리킨다 — 행이 없으면 외래키가 먼저 막는다. */
|
|
74
|
+
async function seedDomain() {
|
|
75
|
+
const saved = await dataSource!.getRepository(shell.Domain).save({ name: 'rename-probe', subdomain: 'rename-probe' })
|
|
76
|
+
DOMAIN = { id: saved.id }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function seedFigure(over: Record<string, unknown> = {}) {
|
|
80
|
+
return await dataSource!.getRepository(Figure).save({
|
|
81
|
+
type: 'FIGURE_MU6SZ5DL',
|
|
82
|
+
name: '초안',
|
|
83
|
+
source: '{}',
|
|
84
|
+
state: 'draft',
|
|
85
|
+
version: 0,
|
|
86
|
+
domain: DOMAIN,
|
|
87
|
+
...over
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const facts = async (figure: any, wanted: string) =>
|
|
92
|
+
await renameFacts(
|
|
93
|
+
{ figures: dataSource!.getRepository(Figure), histories: dataSource!.getRepository(FigureHistory) },
|
|
94
|
+
figure,
|
|
95
|
+
DOMAIN,
|
|
96
|
+
wanted
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
describe('이름을 바꾸기 전에 읽는 사실', () => {
|
|
100
|
+
it('★ 판본 질의가 실제로 돈다 — 칸 이름이 틀리면 여기서 던진다', async () => {
|
|
101
|
+
const figure = await seedFigure()
|
|
102
|
+
|
|
103
|
+
expect(await facts(figure, 'ROBOT_ARM')).toEqual({ version: 0, hasHistory: false, takenByAnother: false })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('★ 판본이 있으면 그것을 읽어 온다', async () => {
|
|
107
|
+
const figure = await seedFigure()
|
|
108
|
+
await dataSource!.getRepository(FigureHistory).save({
|
|
109
|
+
originalId: figure.id,
|
|
110
|
+
version: 1,
|
|
111
|
+
type: figure.type,
|
|
112
|
+
name: figure.name,
|
|
113
|
+
source: '{}',
|
|
114
|
+
domain: DOMAIN
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
expect((await facts(figure, 'ROBOT_ARM')).hasHistory).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('같은 도메인에서 그 이름을 쓰는 다른 도형을 찾는다', async () => {
|
|
121
|
+
const figure = await seedFigure()
|
|
122
|
+
await seedFigure({ type: 'CONVEYOR', name: '컨베이어' })
|
|
123
|
+
|
|
124
|
+
expect((await facts(figure, 'CONVEYOR')).takenByAnother).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('자기 이름은 겹침이 아니다', async () => {
|
|
128
|
+
const figure = await seedFigure()
|
|
129
|
+
|
|
130
|
+
expect((await facts(figure, figure.type)).takenByAnother).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('빈 이름은 겹침을 묻지 않는다 — 규칙이 먼저 거절한다', async () => {
|
|
134
|
+
const figure = await seedFigure()
|
|
135
|
+
|
|
136
|
+
expect((await facts(figure, ' ')).takenByAnother).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 초안의 타입을 고치는 규칙 — ADR-0063 결정 4.
|
|
3
|
+
*
|
|
4
|
+
* 이 규칙이 지키는 것은 하나다: **발행된 이름은 안 바뀐다.** 도면은 그 이름 하나로 도형을
|
|
5
|
+
* 찾으므로, 바뀌면 배치된 자리가 아무 오류 없이 빈다.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'fs'
|
|
8
|
+
import { join } from 'path'
|
|
9
|
+
|
|
10
|
+
import { FigureRenameRefused, renameDraftType } from './figure-rename'
|
|
11
|
+
|
|
12
|
+
const draft = (over: Record<string, unknown> = {}) => ({
|
|
13
|
+
type: 'FIGURE_MU6RJH47',
|
|
14
|
+
source: JSON.stringify({ version: 2, type: 'FIGURE_MU6RJH47', base: { x: 1, y: 1, z: 1 }, parts: [] }),
|
|
15
|
+
...over
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const never = { version: 0, hasHistory: false, takenByAnother: false }
|
|
19
|
+
|
|
20
|
+
describe('초안의 type 고치기', () => {
|
|
21
|
+
it('★ 행과 정본이 함께 바뀐다 — 한쪽만 바뀌면 발행 때 어느 이름이 나갈지 갈린다', () => {
|
|
22
|
+
const result = renameDraftType(draft(), 'ROBOT_ARM', never)
|
|
23
|
+
|
|
24
|
+
expect(result.type).toBe('ROBOT_ARM')
|
|
25
|
+
expect(JSON.parse(result.source).type).toBe('ROBOT_ARM')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('정본의 다른 값은 건드리지 않는다', () => {
|
|
29
|
+
const before = JSON.parse(draft().source)
|
|
30
|
+
const after = JSON.parse(renameDraftType(draft(), 'ROBOT_ARM', never).source)
|
|
31
|
+
|
|
32
|
+
expect({ ...after, type: before.type }).toEqual(before)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('앞뒤 공백을 뗀다. 대소문자는 저작자가 적은 대로 둔다', () => {
|
|
36
|
+
expect(renameDraftType(draft(), ' ROBOT_ARM ', never).type).toBe('ROBOT_ARM')
|
|
37
|
+
expect(renameDraftType(draft(), 'Robot_Arm', never).type).toBe('Robot_Arm')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('★ 한 번이라도 발행됐으면 거절한다 — 판 번호로', () => {
|
|
41
|
+
expect(() => renameDraftType(draft(), 'ROBOT_ARM', { ...never, version: 1 })).toThrow(FigureRenameRefused)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('★ 판 번호가 도로 0 이어도 이력이 있으면 거절한다 — 되돌리기가 그 자리를 만든다', () => {
|
|
45
|
+
try {
|
|
46
|
+
renameDraftType(draft(), 'ROBOT_ARM', { ...never, hasHistory: true })
|
|
47
|
+
throw new Error('거절하지 않았다')
|
|
48
|
+
} catch (e) {
|
|
49
|
+
expect((e as FigureRenameRefused).code).toBe('already-released')
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('같은 도메인에서 쓰는 이름이면 거절하고, 어느 이름인지 말한다', () => {
|
|
54
|
+
try {
|
|
55
|
+
renameDraftType(draft(), 'CONVEYOR', { ...never, takenByAnother: true })
|
|
56
|
+
throw new Error('거절하지 않았다')
|
|
57
|
+
} catch (e) {
|
|
58
|
+
expect((e as FigureRenameRefused).code).toBe('type-taken')
|
|
59
|
+
expect((e as Error).message).toContain('CONVEYOR')
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('빈 이름은 거절한다 — 도면에 적히는 이름이다', () => {
|
|
64
|
+
for (const empty of ['', ' ']) {
|
|
65
|
+
try {
|
|
66
|
+
renameDraftType(draft(), empty, never)
|
|
67
|
+
throw new Error('거절하지 않았다')
|
|
68
|
+
} catch (e) {
|
|
69
|
+
expect((e as FigureRenameRefused).code).toBe('empty-type')
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/*
|
|
76
|
+
* 규칙이 문에 붙어 있는가. 셈이 맞아도 문이 안 부르면 저작자는 그대로 막혀 있다.
|
|
77
|
+
*/
|
|
78
|
+
describe('문이 이 규칙을 지난다', () => {
|
|
79
|
+
const source = (name: string) => readFileSync(join(__dirname, name), 'utf8')
|
|
80
|
+
|
|
81
|
+
it('updateFigure 가 type 을 받고 규칙을 부른다', () => {
|
|
82
|
+
const mutation = source('figure-mutation.ts')
|
|
83
|
+
|
|
84
|
+
expect(/renameDraftType/.test(mutation)).toBe(true)
|
|
85
|
+
expect(/patch\.type !== undefined && patch\.type !== figure\.type/.test(mutation)).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('다듬은 이름이 저장된다 — patch 의 날값이 아니라', () => {
|
|
89
|
+
const mutation = source('figure-mutation.ts')
|
|
90
|
+
|
|
91
|
+
expect(/renamed !== undefined \? \{ type: renamed \}/.test(mutation)).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('문이 type 을 받는 칸을 갖는다', () => {
|
|
95
|
+
const type = source('figure-type.ts')
|
|
96
|
+
|
|
97
|
+
expect(/class FigurePatch \{[\s\S]{0,400}type\?: string/.test(type)).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 초안의 타입을 고치는 규칙 — ADR-0063 결정 4.
|
|
3
|
+
*
|
|
4
|
+
* ## 왜 발행 전에만인가
|
|
5
|
+
*
|
|
6
|
+
* `type` 은 도면에 적히는 이름이다. 도면은 `{ type, left, top, width, height }` 만 갖고 늘 최신
|
|
7
|
+
* 발행판을 그리므로, 발행된 도형의 이름을 바꾸면 이미 배치된 도면이 아무 오류 없이 빈 자리가
|
|
8
|
+
* 된다. 한 번도 발행되지 않은 도형은 그려질 자리가 없으니 가리키는 도면도 있을 수 없다.
|
|
9
|
+
*
|
|
10
|
+
* ## 왜 열어야 했나
|
|
11
|
+
*
|
|
12
|
+
* 닫아 두었더니 저작자가 이름을 **한 번도** 정하지 못했다. 새 Figure 에는 기계가 지은 이름이
|
|
13
|
+
* 붙고(`FIGURE_MU6RJH47`) 칸에 자물쇠가 걸려서, 그 이름이 그대로 도면에 적히는 이름이 됐다.
|
|
14
|
+
* 그렇게 남은 초안이 14 개다.
|
|
15
|
+
*
|
|
16
|
+
* ## 셈은 여기, 질의는 부르는 쪽
|
|
17
|
+
*
|
|
18
|
+
* 이 파일은 저장소를 모른다 — 판 번호 · 이력 유무 · 같은 이름의 주인을 **사실로 받는다.**
|
|
19
|
+
* 그래야 규칙을 DB 없이 시험한다. 문(`figure-mutation.ts`)이 그 셋을 읽어 넘긴다.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** 이름을 바꿀 때 알아야 하는 사실. 부르는 쪽이 저장소에서 읽어 온다. */
|
|
23
|
+
export interface RenameFacts {
|
|
24
|
+
/** 발행 판 번호. 0 이면 한 번도 발행되지 않았다. */
|
|
25
|
+
version?: number
|
|
26
|
+
/** 판본이 하나라도 있나. 되돌리기로 판 번호가 도로 낮아져도 이력은 남는다. */
|
|
27
|
+
hasHistory: boolean
|
|
28
|
+
/** 같은 도메인에서 그 이름을 이미 쓰는 **다른** 도형이 있나. */
|
|
29
|
+
takenByAnother: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class FigureRenameRefused extends Error {
|
|
33
|
+
constructor(
|
|
34
|
+
readonly code: 'empty-type' | 'already-released' | 'type-taken',
|
|
35
|
+
message: string
|
|
36
|
+
) {
|
|
37
|
+
super(message)
|
|
38
|
+
this.name = 'FigureRenameRefused'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 고친 이름과 정본. 못 고치면 **까닭과 함께 던진다** — 조용히 옛 이름으로 두지 않는다.
|
|
44
|
+
*
|
|
45
|
+
* 정본 안에도 `type` 이 있어서 함께 고친다. 한쪽만 고치면 저장된 JSON 과 행이 다른 이름을
|
|
46
|
+
* 말하고, 발행할 때 어느 쪽이 도면에 적히는지가 갈린다.
|
|
47
|
+
*/
|
|
48
|
+
export function renameDraftType(
|
|
49
|
+
current: { type: string; source: string },
|
|
50
|
+
wanted: string,
|
|
51
|
+
facts: RenameFacts
|
|
52
|
+
): { type: string; source: string } {
|
|
53
|
+
const next = wanted.trim()
|
|
54
|
+
|
|
55
|
+
if (!next) {
|
|
56
|
+
throw new FigureRenameRefused('empty-type', 'type 이 비어 있다 — 도면에 적히는 이름이라 비울 수 없다')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if ((facts.version ?? 0) > 0 || facts.hasHistory) {
|
|
60
|
+
throw new FigureRenameRefused(
|
|
61
|
+
'already-released',
|
|
62
|
+
'발행된 도형의 type 은 바꿀 수 없다 — 이미 배치된 도면이 이 이름을 가리킨다'
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (facts.takenByAnother) {
|
|
67
|
+
throw new FigureRenameRefused('type-taken', `type 「${next}」 는 이 도메인에서 이미 쓰고 있다`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const parsed = JSON.parse(current.source) as { type?: string }
|
|
71
|
+
parsed.type = next
|
|
72
|
+
|
|
73
|
+
return { type: next, source: JSON.stringify(parsed) }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 사실 셋을 저장소에서 읽는다.
|
|
78
|
+
*
|
|
79
|
+
* 규칙에서 떼어 둔 자리이지만 **질의의 모양이 틀리면 저작자에게는 저장 실패로만 보인다.**
|
|
80
|
+
* 실제로 그랬다 — 판본을 `figure: { id }` 로 물었고, `FigureHistory` 는 관계 칸이 아니라
|
|
81
|
+
* `originalId` 로 가리킨다. TypeORM 이 「그런 속성이 없다」로 던졌고 화면에는 영문 오류 한 줄이
|
|
82
|
+
* 떴다. 그래서 이 함수를 내보내 sqlite 를 들고 왕복까지 본다.
|
|
83
|
+
*/
|
|
84
|
+
export async function renameFacts(
|
|
85
|
+
repositories: {
|
|
86
|
+
figures: { findOne(options: any): Promise<{ id: string } | null> }
|
|
87
|
+
histories: { count(options: any): Promise<number> }
|
|
88
|
+
},
|
|
89
|
+
figure: { id?: string; version?: number },
|
|
90
|
+
domain: { id: string },
|
|
91
|
+
wanted: string
|
|
92
|
+
): Promise<RenameFacts> {
|
|
93
|
+
const next = wanted.trim()
|
|
94
|
+
const hasHistory = (await repositories.histories.count({ where: { originalId: figure.id } })) > 0
|
|
95
|
+
const taken = next ? await repositories.figures.findOne({ where: { domain: { id: domain.id }, type: next } }) : null
|
|
96
|
+
|
|
97
|
+
return { version: figure.version, hasHistory, takenByAnother: !!taken && taken.id !== figure.id }
|
|
98
|
+
}
|