@things-factory/figure-service 10.1.28 → 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-mutation.d.ts +7 -0
- package/dist-server/service/figure/figure-mutation.js +30 -0
- 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-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-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-mutation.ts +44 -0
- package/server/service/figure/figure-propose.ts +1 -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 +105 -0
- 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
|
}
|
|
@@ -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
|
*
|
|
@@ -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:',
|
|
@@ -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
|
+
}
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* 실제로 등록된 명세를 등록기에 넣고, 등록기가 무엇도 문제 삼지 않는지 묻는다. 도구 목록이 늘면
|
|
6
6
|
* 이 시험이 먼저 문다.
|
|
7
7
|
*/
|
|
8
|
+
import { readdirSync, readFileSync } from 'fs'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
|
|
8
11
|
import { clearToolRegistry, getAllToolSpecs, getToolRegistryWarnings } from '@things-factory/ai-client-base'
|
|
9
12
|
|
|
10
13
|
import { registerFigureToolCategories } from './figure-tools'
|
|
@@ -36,3 +39,105 @@ describe('figure tools — declared doors', () => {
|
|
|
36
39
|
expect(getToolRegistryWarnings().map(w => `${w.code}:${w.params.tool ?? ''}`)).toEqual([])
|
|
37
40
|
})
|
|
38
41
|
})
|
|
42
|
+
|
|
43
|
+
/*
|
|
44
|
+
* 베낀 권한이 문의 지시자와 같은가 — ADR-0072 결정 5 (b).
|
|
45
|
+
*
|
|
46
|
+
* 권한은 문의 지시자에서 나와야 하는데(ADR-0071 결정 2) 지금은 읽을 자리가 없다: 지시자가 남기는
|
|
47
|
+
* 것은 (category, privilege) 쌍 목록뿐이고 필드별 지도가 없다. 그래서 도구 명세가 권한을 베껴
|
|
48
|
+
* 적는다. 베낀 값은 **문이 바뀌어도 안 따라간다** — 문에 권한을 붙이거나 떼거나 이름을 고치면
|
|
49
|
+
* 도구 명세만 옛 값을 들고 남고, 그때 게이트는 문이 안 묻는 것을 묻거나 문이 묻는 것을 안 묻는다.
|
|
50
|
+
*
|
|
51
|
+
* 여기서는 리졸버 원문의 지시자를 읽어 도구가 적은 것과 견준다. AUTH 에 청한 필드별 지도가 서면
|
|
52
|
+
* 이 시험과 베끼기가 함께 없어진다.
|
|
53
|
+
*/
|
|
54
|
+
describe('figure tools — the copied privilege matches the door', () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
clearToolRegistry()
|
|
57
|
+
registerFigureToolCategories()
|
|
58
|
+
})
|
|
59
|
+
afterAll(() => clearToolRegistry())
|
|
60
|
+
|
|
61
|
+
/** 리졸버 원문에서 `Query.figures` 같은 문 이름 → 그 문의 `@privilege` 지시자. */
|
|
62
|
+
const doorPrivileges = (): Map<string, { category: string; privilege: string; owner: boolean }> => {
|
|
63
|
+
const found = new Map<string, { category: string; privilege: string; owner: boolean }>()
|
|
64
|
+
|
|
65
|
+
for (const file of readdirSync(__dirname).filter(one => one.endsWith('.ts') && !one.endsWith('.test.ts'))) {
|
|
66
|
+
const lines = readFileSync(join(__dirname, file), 'utf8').split('\n')
|
|
67
|
+
let privilege: { category: string; privilege: string; owner: boolean } | undefined
|
|
68
|
+
let kind: 'Query' | 'Mutation' | undefined
|
|
69
|
+
let decorator = ''
|
|
70
|
+
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
const declared = /@privilege\(category: "([^"]+)", privilege: "([^"]+)"(, domainOwnerGranted: (true|false))?\)/.exec(line)
|
|
73
|
+
if (declared) {
|
|
74
|
+
privilege = { category: declared[1]!, privilege: declared[2]!, owner: declared[4] === 'true' }
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const opens = /@(Query|Mutation)\(/.exec(line)
|
|
79
|
+
if (opens) {
|
|
80
|
+
kind = opens[1] as 'Query' | 'Mutation'
|
|
81
|
+
decorator = line
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!kind) {
|
|
86
|
+
/* 지시자와 문 사이에 딴 것이 오면 그 지시자는 이 문의 것이 아니다. */
|
|
87
|
+
if (line.trim() && !line.trim().startsWith('@') && !line.trim().startsWith('*') && !line.trim().startsWith('/*')) {
|
|
88
|
+
privilege = undefined
|
|
89
|
+
}
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
decorator += '\n' + line
|
|
94
|
+
const method = /^\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/.exec(line)
|
|
95
|
+
if (!method) continue
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
필드 이름이 메서드 이름과 다를 수 있는 길은 하나뿐이다 — 데코레이터의 `name:`. 그것이
|
|
99
|
+
나타나면 조용히 메서드 이름으로 읽지 않고 멈춘다. 잘못 읽은 짝은 초록으로 보인다.
|
|
100
|
+
*/
|
|
101
|
+
if (/\bname:\s*'/.test(decorator)) {
|
|
102
|
+
throw new Error(`${file} 의 ${kind} 가 데코레이터에서 이름을 정한다 — 이 시험이 그 길을 아직 안 읽는다`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (privilege) found.set(`${kind}.${method[1]}`, privilege)
|
|
106
|
+
kind = undefined
|
|
107
|
+
privilege = undefined
|
|
108
|
+
decorator = ''
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return found
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
it('★ 도구가 적은 권한이 그 문의 지시자와 한 글자도 다르지 않다', () => {
|
|
116
|
+
const doors = doorPrivileges()
|
|
117
|
+
|
|
118
|
+
const mismatched = getAllToolSpecs().flatMap(spec =>
|
|
119
|
+
((spec as any).doors ?? []).map((door: string) => {
|
|
120
|
+
const declared = doors.get(door)
|
|
121
|
+
const copied = (spec as any).privilege
|
|
122
|
+
if (!declared) return `${spec.name} → ${door}: 그런 문이 없거나 그 문이 권한을 선언하지 않는다`
|
|
123
|
+
if (
|
|
124
|
+
declared.category !== copied?.category ||
|
|
125
|
+
declared.privilege !== copied?.privilege ||
|
|
126
|
+
declared.owner !== (copied?.owner ?? false)
|
|
127
|
+
) {
|
|
128
|
+
return `${spec.name} → ${door}: 문은 ${JSON.stringify(declared)} 인데 도구는 ${JSON.stringify(copied)}`
|
|
129
|
+
}
|
|
130
|
+
return undefined
|
|
131
|
+
})
|
|
132
|
+
).filter(Boolean)
|
|
133
|
+
|
|
134
|
+
expect(mismatched).toEqual([])
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('견줄 문을 실제로 찾아 읽었다 — 0 건이면 위 시험은 언제나 초록이다', () => {
|
|
138
|
+
const doors = doorPrivileges()
|
|
139
|
+
expect(doors.get('Query.figures')).toEqual({ category: 'figure', privilege: 'query', owner: true })
|
|
140
|
+
expect(doors.get('Mutation.proposeFigure')).toEqual({ category: 'figure', privilege: 'mutation', owner: true })
|
|
141
|
+
expect(doors.size).toBeGreaterThanOrEqual(4)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -41,11 +41,19 @@ export class NewFigure {
|
|
|
41
41
|
/**
|
|
42
42
|
* 수정.
|
|
43
43
|
*
|
|
44
|
-
* `type`
|
|
45
|
-
* 타입을 못 찾아 오류 없이 빈 화면이 된다.
|
|
44
|
+
* `type` 은 **한 번도 발행되지 않은 초안에서만** 바뀐다(ADR-0063 결정 4). 저장되는 식별자라
|
|
45
|
+
* 발행된 뒤에 바꾸면 이미 배치된 보드가 타입을 못 찾아 오류 없이 빈 화면이 된다. 발행 전에는
|
|
46
|
+
* 그 도형을 가리키는 도면이 있을 수 없다 — 도면은 최신 발행판을 그리므로 발행된 적 없는
|
|
47
|
+
* 도형은 그려질 자리가 없다.
|
|
48
|
+
*
|
|
49
|
+
* 닫아 두었더니 저작자가 이름을 한 번도 정하지 못했다. 새 Figure 에는 기계가 지은 이름이
|
|
50
|
+
* 붙고(`FIGURE_MU6RJH47`) 칸에 자물쇠가 걸려서, 그 이름이 그대로 도면에 적히는 이름이 됐다.
|
|
46
51
|
*/
|
|
47
|
-
@InputType({ description: 'Input for updating an existing figure.
|
|
52
|
+
@InputType({ description: 'Input for updating an existing figure. `type` is accepted only while the figure has never been released.' })
|
|
48
53
|
export class FigurePatch {
|
|
54
|
+
@Field({ nullable: true, description: 'The stored identifier. Accepted only before the first release; rejected once a version exists.' })
|
|
55
|
+
type?: string
|
|
56
|
+
|
|
49
57
|
@Field({ nullable: true, description: 'The new display name.' })
|
|
50
58
|
name?: string
|
|
51
59
|
|