@things-factory/figure-ui 10.1.28 → 10.1.31

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 (62) hide show
  1. package/client/{themes → harness-theme}/brand.css +15 -4
  2. package/{dist-client/themes → client/harness-theme}/dark.css +4 -4
  3. package/client/{themes → harness-theme}/light.css +6 -6
  4. package/client/modeller/angle-dial.ts +70 -0
  5. package/client/modeller/figure-animations.ts +9 -9
  6. package/client/modeller/figure-canvas.ts +6 -3
  7. package/client/modeller/figure-inspector.ts +18 -3
  8. package/client/modeller/figure-preview.ts +56 -4
  9. package/client/modeller/figure-report.ts +11 -28
  10. package/client/modeller/figure-side.ts +1 -0
  11. package/client/modeller/joint-gizmo.ts +63 -1
  12. package/client/modeller/part-edits.ts +75 -9
  13. package/client/pages/figure-modeller-page.ts +20 -2
  14. package/client/types.ts +2 -0
  15. package/dist-client/{themes → harness-theme}/brand.css +15 -4
  16. package/{client/themes → dist-client/harness-theme}/dark.css +4 -4
  17. package/dist-client/{themes → harness-theme}/light.css +6 -6
  18. package/dist-client/modeller/angle-dial.d.ts +32 -0
  19. package/dist-client/modeller/angle-dial.js +41 -0
  20. package/dist-client/modeller/angle-dial.js.map +1 -0
  21. package/dist-client/modeller/figure-animations.d.ts +4 -4
  22. package/dist-client/modeller/figure-animations.js +9 -9
  23. package/dist-client/modeller/figure-animations.js.map +1 -1
  24. package/dist-client/modeller/figure-canvas.js +6 -3
  25. package/dist-client/modeller/figure-canvas.js.map +1 -1
  26. package/dist-client/modeller/figure-inspector.d.ts +11 -0
  27. package/dist-client/modeller/figure-inspector.js +20 -3
  28. package/dist-client/modeller/figure-inspector.js.map +1 -1
  29. package/dist-client/modeller/figure-preview.d.ts +11 -0
  30. package/dist-client/modeller/figure-preview.js +53 -3
  31. package/dist-client/modeller/figure-preview.js.map +1 -1
  32. package/dist-client/modeller/figure-report.d.ts +8 -4
  33. package/dist-client/modeller/figure-report.js +11 -31
  34. package/dist-client/modeller/figure-report.js.map +1 -1
  35. package/dist-client/modeller/figure-side.js +1 -0
  36. package/dist-client/modeller/figure-side.js.map +1 -1
  37. package/dist-client/modeller/joint-gizmo.d.ts +4 -1
  38. package/dist-client/modeller/joint-gizmo.js +54 -0
  39. package/dist-client/modeller/joint-gizmo.js.map +1 -1
  40. package/dist-client/modeller/part-edits.d.ts +29 -4
  41. package/dist-client/modeller/part-edits.js +61 -9
  42. package/dist-client/modeller/part-edits.js.map +1 -1
  43. package/dist-client/pages/figure-modeller-page.d.ts +7 -0
  44. package/dist-client/pages/figure-modeller-page.js +20 -2
  45. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  46. package/dist-client/tsconfig.tsbuildinfo +1 -1
  47. package/dist-client/types.d.ts +2 -0
  48. package/dist-client/types.js.map +1 -1
  49. package/package.json +5 -5
  50. package/test/angle-dial.test.ts +78 -0
  51. package/test/base-box.test.ts +11 -0
  52. package/test/brand-palette.test.ts +99 -18
  53. package/test/expect.ts +2 -0
  54. package/test/i18n-prefix-guard.test.ts +17 -0
  55. package/test/joint-needle.test.ts +162 -0
  56. package/test/part-edits.test.ts +100 -0
  57. package/test/scene-budget.test.ts +29 -0
  58. package/translations/en.json +3 -7
  59. package/translations/ja.json +3 -7
  60. package/translations/ko.json +3 -7
  61. package/translations/ms.json +3 -7
  62. package/translations/zh.json +3 -7
@@ -81,6 +81,8 @@ export interface NewFigure {
81
81
  * 찾으므로, 바꾸면 그 도면이 빈 화면이 된다.
82
82
  */
83
83
  export interface FigurePatch {
84
+ /** 발행 전 초안에서만 받는다 — 발행된 뒤에는 문이 거절한다(ADR-0063 결정 4). */
85
+ type?: string;
84
86
  name?: string;
85
87
  description?: string;
86
88
  category?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../client/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * 화면이 다루는 모양.\n *\n * 정본 형식(`FigureSource`)의 타입은 **여기서 다시 정의하지 않는다** —\n * `@hatiolab/figure-model` 에서 가져온다. 두 벌이면 갈리고, 갈리면 화면이 서버가\n * 거절할 것을 만들어 낸다.\n */\nexport type { FigureSource, FigureBlueprint, FigurePart, ErrorCode, ViolationCode } from '@hatiolab/figure-model'\n\n/** 서버가 돌려주는 Figure 한 벌. `source` 는 저작면에서만 받는다. */\nexport interface Figure {\n id: string\n /** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */\n type: string\n name: string\n description?: string\n /** 분류 하나 — 팔레트에서 묶어 보이는 기준. */\n category?: string\n /** 태그 여럿 — 분류로는 못 하는 찾기. */\n tags?: string[]\n state?: 'draft' | 'released'\n /** 재활용 점수 0~100. 서버가 정본에서 계산한다 — 화면이 보내는 값이 아니다. */\n score?: number\n triangles?: number\n groups?: number\n thumbnail?: string\n thumbnailUpdatedAt?: string\n updatedAt?: string\n updater?: { id: string; name: string }\n /** `FigureSource` JSON 문자열. 목록 질의에는 실려 오지 않는다. */\n source?: string\n properties?: string\n version?: number\n}\n\n/**\n * 발행된 판 하나.\n *\n * 저작 중 저장은 여기 오지 않는다 — 판본은 발행에서만 생긴다.\n */\nexport interface FigureVersion {\n version: number\n /** 발행하며 남긴 말. */\n comment?: string\n state?: 'draft' | 'released'\n updatedAt?: string\n updater?: { id: string; name: string }\n score?: number\n triangles?: number\n groups?: number\n}\n\nexport interface FigureListResult {\n items: Figure[]\n total: number\n}\n\n/**\n * 새 Figure.\n *\n * `score`·`triangles`·`groups` 가 없다 — **서버가 정본에서 계산한다.** 화면이 보내면\n * 거짓이 들어오고 목록이 실물과 다른 것을 보여 준다.\n */\nexport interface NewFigure {\n type: string\n name: string\n description?: string\n category?: string\n tags?: string[]\n source: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/**\n * 수정.\n *\n * `type` 이 없다 — 저장되는 식별자라 고칠 수 없다. 배치된 도면이 그 이름으로 타입을\n * 찾으므로, 바꾸면 그 도면이 빈 화면이 된다.\n */\nexport interface FigurePatch {\n name?: string\n description?: string\n category?: string\n /** 빈 배열은 「전부 지운다」, 안 주는 것은 「건드리지 않는다」. */\n tags?: string[]\n source?: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/** 검증 결과 한 줄. 코드는 figure-model 의 것을 그대로 쓴다. */\nexport interface FigureFinding {\n code: string\n message: string\n at?: string\n}\n\n/**\n * 저작 보조가 낸 후보.\n *\n * **정본이 아니다.** 저작자가 보고 받아야 정본이 된다 — 서버는 저장하지 않고\n * 돌려주기만 한다.\n */\nexport interface FigureProposal {\n /** 후보 정본. JSON 문자열이다 — 화면이 파싱한다. */\n source: string\n score: number\n grade: string\n triangles: number\n /** 재질 묶음 = 인스턴스 하나가 무는 draw call. */\n groups: number\n /** Rule-based review of measurable design concerns; it is not an aesthetic verdict. */\n quality: {\n status: 'ready' | 'review'\n summary: string\n findings: Array<{ dimension: string; code: string; message: string }>\n visualRegions: number\n visualCoverage: number\n visualReadability: number\n }\n /** 형식은 맞으나 정책을 넘은 것. 막지 않는다 — 저작자가 정한다. */\n violations: FigureFinding[]\n /** 몇 번 만에 됐나. 진단용이다. */\n attempts: number\n}\n\n/** 사람이 후보를 수락하거나 버릴 때 직접 남긴 현재 저작 세션의 참고 신호. */\nexport interface ProposalFeedback {\n outcome: 'accepted' | 'discarded'\n selectedChanges: number\n totalChanges: number\n note?: string\n grade?: string\n quality?: { status?: 'ready' | 'review'; findingCodes?: string[] }\n}\n\n/**\n * 발행 판정 한 줄.\n *\n * `FigureFinding` 과 달리 **`blocking` 이 있다.** 저장 쪽 발견은 정의상 막지 않는 것들이고,\n * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고\n * 화면이 다시 분류하면 규칙이 두 벌이 된다.\n */\n/** One reversible anchor edit the sizing gate has measured against this exact source. */\nexport interface FigureGateFix {\n findingWay: string\n part: string\n axis: 'x' | 'y' | 'z'\n from?: string\n to: 'min' | 'center' | 'max' | 'span'\n resolved: number\n /** All blocking sizing cases clear after this exact edit was remeasured server-side. */\n safe: boolean\n}\n\nexport interface FigureGateFinding {\n code: string\n message: string\n /** 왜 그것이 문제인가 — 사람이 무엇을 보게 되나. */\n why?: string\n /** 무엇을 바꾸면 되나 — 부품 이름과 값으로. */\n how?: string\n /** 갈라진 부품 이름들, 또는 정본 안의 경로. */\n at?: string\n /** Candidate edits; the UI must preview and explicitly apply these, never auto-save them. */\n fixes?: FigureGateFix[]\n blocking: boolean\n}\n\n/** 발행해도 되나. `blocked` 면 서버가 거절한다. */\nexport interface FigureInspection {\n blocked: boolean\n findings: FigureGateFinding[]\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../client/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * 화면이 다루는 모양.\n *\n * 정본 형식(`FigureSource`)의 타입은 **여기서 다시 정의하지 않는다** —\n * `@hatiolab/figure-model` 에서 가져온다. 두 벌이면 갈리고, 갈리면 화면이 서버가\n * 거절할 것을 만들어 낸다.\n */\nexport type { FigureSource, FigureBlueprint, FigurePart, ErrorCode, ViolationCode } from '@hatiolab/figure-model'\n\n/** 서버가 돌려주는 Figure 한 벌. `source` 는 저작면에서만 받는다. */\nexport interface Figure {\n id: string\n /** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */\n type: string\n name: string\n description?: string\n /** 분류 하나 — 팔레트에서 묶어 보이는 기준. */\n category?: string\n /** 태그 여럿 — 분류로는 못 하는 찾기. */\n tags?: string[]\n state?: 'draft' | 'released'\n /** 재활용 점수 0~100. 서버가 정본에서 계산한다 — 화면이 보내는 값이 아니다. */\n score?: number\n triangles?: number\n groups?: number\n thumbnail?: string\n thumbnailUpdatedAt?: string\n updatedAt?: string\n updater?: { id: string; name: string }\n /** `FigureSource` JSON 문자열. 목록 질의에는 실려 오지 않는다. */\n source?: string\n properties?: string\n version?: number\n}\n\n/**\n * 발행된 판 하나.\n *\n * 저작 중 저장은 여기 오지 않는다 — 판본은 발행에서만 생긴다.\n */\nexport interface FigureVersion {\n version: number\n /** 발행하며 남긴 말. */\n comment?: string\n state?: 'draft' | 'released'\n updatedAt?: string\n updater?: { id: string; name: string }\n score?: number\n triangles?: number\n groups?: number\n}\n\nexport interface FigureListResult {\n items: Figure[]\n total: number\n}\n\n/**\n * 새 Figure.\n *\n * `score`·`triangles`·`groups` 가 없다 — **서버가 정본에서 계산한다.** 화면이 보내면\n * 거짓이 들어오고 목록이 실물과 다른 것을 보여 준다.\n */\nexport interface NewFigure {\n type: string\n name: string\n description?: string\n category?: string\n tags?: string[]\n source: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/**\n * 수정.\n *\n * `type` 이 없다 — 저장되는 식별자라 고칠 수 없다. 배치된 도면이 그 이름으로 타입을\n * 찾으므로, 바꾸면 그 도면이 빈 화면이 된다.\n */\nexport interface FigurePatch {\n /** 발행 전 초안에서만 받는다 — 발행된 뒤에는 문이 거절한다(ADR-0063 결정 4). */\n type?: string\n name?: string\n description?: string\n category?: string\n /** 빈 배열은 「전부 지운다」, 안 주는 것은 「건드리지 않는다」. */\n tags?: string[]\n source?: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/** 검증 결과 한 줄. 코드는 figure-model 의 것을 그대로 쓴다. */\nexport interface FigureFinding {\n code: string\n message: string\n at?: string\n}\n\n/**\n * 저작 보조가 낸 후보.\n *\n * **정본이 아니다.** 저작자가 보고 받아야 정본이 된다 — 서버는 저장하지 않고\n * 돌려주기만 한다.\n */\nexport interface FigureProposal {\n /** 후보 정본. JSON 문자열이다 — 화면이 파싱한다. */\n source: string\n score: number\n grade: string\n triangles: number\n /** 재질 묶음 = 인스턴스 하나가 무는 draw call. */\n groups: number\n /** Rule-based review of measurable design concerns; it is not an aesthetic verdict. */\n quality: {\n status: 'ready' | 'review'\n summary: string\n findings: Array<{ dimension: string; code: string; message: string }>\n visualRegions: number\n visualCoverage: number\n visualReadability: number\n }\n /** 형식은 맞으나 정책을 넘은 것. 막지 않는다 — 저작자가 정한다. */\n violations: FigureFinding[]\n /** 몇 번 만에 됐나. 진단용이다. */\n attempts: number\n}\n\n/** 사람이 후보를 수락하거나 버릴 때 직접 남긴 현재 저작 세션의 참고 신호. */\nexport interface ProposalFeedback {\n outcome: 'accepted' | 'discarded'\n selectedChanges: number\n totalChanges: number\n note?: string\n grade?: string\n quality?: { status?: 'ready' | 'review'; findingCodes?: string[] }\n}\n\n/**\n * 발행 판정 한 줄.\n *\n * `FigureFinding` 과 달리 **`blocking` 이 있다.** 저장 쪽 발견은 정의상 막지 않는 것들이고,\n * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고\n * 화면이 다시 분류하면 규칙이 두 벌이 된다.\n */\n/** One reversible anchor edit the sizing gate has measured against this exact source. */\nexport interface FigureGateFix {\n findingWay: string\n part: string\n axis: 'x' | 'y' | 'z'\n from?: string\n to: 'min' | 'center' | 'max' | 'span'\n resolved: number\n /** All blocking sizing cases clear after this exact edit was remeasured server-side. */\n safe: boolean\n}\n\nexport interface FigureGateFinding {\n code: string\n message: string\n /** 왜 그것이 문제인가 — 사람이 무엇을 보게 되나. */\n why?: string\n /** 무엇을 바꾸면 되나 — 부품 이름과 값으로. */\n how?: string\n /** 갈라진 부품 이름들, 또는 정본 안의 경로. */\n at?: string\n /** Candidate edits; the UI must preview and explicitly apply these, never auto-save them. */\n fixes?: FigureGateFix[]\n blocking: boolean\n}\n\n/** 발행해도 되나. `blocked` 면 서버가 거절한다. */\nexport interface FigureInspection {\n blocked: boolean\n findings: FigureGateFinding[]\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.28",
3
+ "version": "10.1.31",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -26,8 +26,8 @@
26
26
  "clean": "npm run clean:server && npm run clean:client"
27
27
  },
28
28
  "dependencies": {
29
- "@hatiolab/figure-model": "^0.1.21",
30
- "@hatiolab/things-scene": "^10.1.14",
29
+ "@hatiolab/figure-model": "^0.1.27",
30
+ "@hatiolab/things-scene": "^10.1.22",
31
31
  "@material/web": "^2.0.0",
32
32
  "@operato/data-grist": "^10.0.0",
33
33
  "@operato/graphql": "^10.0.0",
@@ -37,8 +37,8 @@
37
37
  "@operato/shell": "^10.0.0",
38
38
  "@operato/styles": "^10.0.0",
39
39
  "@operato/utils": "^10.0.0",
40
- "@things-factory/figure-service": "^10.1.28",
40
+ "@things-factory/figure-service": "^10.1.31",
41
41
  "three": "^0.185.1"
42
42
  },
43
- "gitHead": "3a260a0f79bc6b41a692a103955bfcc541aff60e"
43
+ "gitHead": "5213e6f9913b40ea2618a2111d9884b792dbaa01"
44
44
  }
@@ -0,0 +1,78 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { readFileSync } from 'node:fs'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ import { dialShape } from '../client/modeller/angle-dial.ts'
8
+
9
+ /*
10
+ * 막대 옆의 각도 눈.
11
+ *
12
+ * 관절이 돌았다는 것을 3D 안에서만 보이려 했다가 잰 값에 막혔다 — 미리보기의 부품에 단 바늘이
13
+ * 4 × 2 픽셀이었다. 여덟 대를 한 화면에 세우는 자리라 한 대가 20 픽셀쯤이고, 그 안의 표시는
14
+ * 무엇이든 몇 픽셀을 못 넘는다. 그래서 각은 3D 의 배율에서 떼어 이 눈이 읽는다.
15
+ */
16
+
17
+ const near = (got: number, want: number, why: string) => assert.ok(Math.abs(got - want) < 0.05, `${why}: ${got}`)
18
+
19
+ describe('각도 눈', () => {
20
+ it('0 도는 위를 가리킨다', () => {
21
+ const dial = dialShape(-90, 90, 0)
22
+
23
+ near(dial.needle.x, dial.centre.x, '가운데에서 안 벗어난다')
24
+ near(dial.needle.y, dial.centre.y - dial.radius, '위로 반지름만큼')
25
+ })
26
+
27
+ it('양수는 시계 방향이다 — 화면의 각과 같은 쪽', () => {
28
+ const dial = dialShape(-180, 180, 90)
29
+
30
+ near(dial.needle.x, dial.centre.x + dial.radius, '오른쪽')
31
+ near(dial.needle.y, dial.centre.y, '높이는 가운데')
32
+ })
33
+
34
+ it('음수는 반대쪽이다', () => {
35
+ const dial = dialShape(-180, 180, -90)
36
+ near(dial.needle.x, dial.centre.x - dial.radius, '왼쪽')
37
+ })
38
+
39
+ it('범위 밖의 값은 범위 끝에 선다 — 밖을 가리키는 바늘은 없는 값을 말한다', () => {
40
+ const over = dialShape(-90, 90, 200)
41
+ const under = dialShape(-90, 90, -200)
42
+
43
+ assert.deepEqual(over.needle, dialShape(-90, 90, 90).needle)
44
+ assert.deepEqual(under.needle, dialShape(-90, 90, -90).needle)
45
+ })
46
+
47
+ it('180 도를 넘는 범위는 큰 호로 그린다 — 안 그러면 부채꼴이 반대쪽으로 패인다', () => {
48
+ const wide = dialShape(-150, 150, 0)
49
+ const narrow = dialShape(-45, 45, 0)
50
+
51
+ assert.ok(/A 9 9 0 1 1/.test(wide.sector), `큰 호: ${wide.sector}`)
52
+ assert.ok(/A 9 9 0 0 1/.test(narrow.sector), `작은 호: ${narrow.sector}`)
53
+ })
54
+
55
+ it('한 바퀴는 원이다 — 부채꼴의 두 변이 겹쳐 그어지지 않는다', () => {
56
+ const full = dialShape(-180, 180, 45)
57
+
58
+ assert.ok(!full.sector.includes('L'), `변을 긋지 않는다: ${full.sector}`)
59
+ assert.equal((full.sector.match(/A /g) ?? []).length, 2, '반원 둘로 원을 만든다')
60
+ })
61
+ })
62
+
63
+ describe('눈이 화면에 붙어 있다', () => {
64
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
65
+
66
+ it('미리보기의 파라미터 줄이 눈을 그린다', () => {
67
+ const preview = read('../client/modeller/figure-preview.ts')
68
+
69
+ assert.ok(/this\.renderDial\(range, value\)/.test(preview), '막대 옆에 있다')
70
+ assert.ok(/dialShape\(range\.min, range\.max, value\)/.test(preview), '값으로 그린다')
71
+ })
72
+
73
+ it('각이 아닌 파라미터에는 안 단다', () => {
74
+ const preview = read('../client/modeller/figure-preview.ts')
75
+
76
+ assert.ok(/range\.unit !== 'deg'/.test(preview), 'deg 만')
77
+ })
78
+ })
@@ -138,6 +138,17 @@ describe('화면에 붙어 있다', () => {
138
138
  assert.ok(/private resizeBase\(draft: FigureDraft, parts: PartModel\[\]\) \{\s*\n\s*this\.recordHistory\('base'\)/.test(page))
139
139
  })
140
140
 
141
+ it('발행 전에는 타입 칸이 열려 있다 — 기계가 지은 이름을 고칠 자리가 있어야 한다', () => {
142
+ const inspector = source('../client/modeller/figure-inspector.ts')
143
+ const side = source('../client/modeller/figure-side.ts')
144
+ const page = source('../client/pages/figure-modeller-page.ts')
145
+
146
+ /* 판 번호가 0 이면 고칠 수 있다. 「저장됐나」로 가르면 저장하는 순간 굳는다. */
147
+ assert.ok(/figureVersion \?\? 0\) > 0/.test(inspector), '판 번호로 가른다')
148
+ assert.ok(/<figure-inspector[\s\S]{0,200}\.figureVersion=/.test(side), '판 번호가 검사기까지 온다')
149
+ assert.ok(/type: this\.figure\.type/.test(page), '저장할 때 행의 타입도 함께 보낸다')
150
+ })
151
+
141
152
  it('발행된 도형을 고치는 동안 그 자리에서 말한다', () => {
142
153
  const page = source('../client/pages/figure-modeller-page.ts')
143
154
  const inspector = source('../client/modeller/figure-inspector.ts')
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict'
2
- import { readFileSync } from 'node:fs'
3
- import { join } from 'node:path'
2
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
3
+ import { join, relative } from 'node:path'
4
4
  import test from 'node:test'
5
5
 
6
6
  /**
@@ -22,10 +22,13 @@ import test from 'node:test'
22
22
  */
23
23
 
24
24
  const HERE = import.meta.dirname
25
- const BRAND = join(HERE, '..', 'client', 'themes', 'brand.css')
26
- const THEMES = ['light.css', 'dark.css'].map(f => join(HERE, '..', 'client', 'themes', f))
27
- /** 저작 하네스. 이 저장소 안의 두 번째 사본 자리다. */
28
- const HARNESS = join(HERE, '..', '..', '..', 'test', 'browser', 'modeller-page.mjs')
25
+ const FOLDER = 'harness-theme'
26
+ const BRAND = join(HERE, '..', 'client', FOLDER, 'brand.css')
27
+ const THEMES = ['light.css', 'dark.css'].map(f => join(HERE, '..', 'client', FOLDER, f))
28
+ /** 저작 하네스 둘. 이 저장소 안의 두 번째 사본 자리다. */
29
+ const HARNESSES = ['modeller-page.mjs', 'real-modeller.mjs'].map(f =>
30
+ join(HERE, '..', '..', '..', 'test', 'browser', f)
31
+ )
29
32
 
30
33
  const read = (path: string) => readFileSync(path, 'utf8')
31
34
 
@@ -69,7 +72,7 @@ function colorOf(css: string, name: string): string {
69
72
  test('브랜드 색의 정본이 있다 — 없으면 아래 테스트가 다 통과해 버린다', () => {
70
73
  const brand = definitions(read(BRAND))
71
74
 
72
- for (const name of ['--brand-figure', '--brand-figure-2', '--brand-figure-wash']) {
75
+ for (const name of ['--figure-brand', '--figure-brand-2', '--figure-brand-wash']) {
73
76
  assert.ok(brand.has(name), `${name} 이 brand.css 에 없다`)
74
77
  }
75
78
  })
@@ -92,30 +95,108 @@ test('하네스가 색을 스스로 정하지 않는다 — 이것이 원래 결
92
95
  * 하네스에서 보이는 화면과 앱에서 보일 화면이 다른 색이었고, 어느 쪽이 맞는지 알 방법이
93
96
  * 없었다.
94
97
  */
95
- const css = read(HARNESS)
96
- const own = [...definitions(css).keys()].filter(
97
- name => name.startsWith('--md-sys-color-') || name.startsWith('--brand-')
98
+ for (const path of HARNESSES) {
99
+ const own = [...definitions(read(path)).keys()].filter(
100
+ name => name.startsWith('--md-sys-color-') || name.startsWith('--figure-')
101
+ )
102
+
103
+ assert.deepEqual(own, [], `${path} 가 색을 스스로 정한다: ${own.join(' · ')}`)
104
+ }
105
+ })
106
+
107
+ /*
108
+ 이 셋은 `client/themes` 에 있었다. `@things-factory/builder` 의 `ThemeOverridePlugin` 이 모든 모듈의
109
+ `client/themes/*.css` 를 **파일 이름 하나당 하나만** 테마 묶음에 싣는다(앱이 맨 앞). 그래서 그 자리에
110
+ 두면 두 가지로 무너진다 — 앱이 같은 이름을 갖는 순간 통째로 빠지거나(실제로 `light.css` · `dark.css`
111
+ 가 figure 앱에서 그랬다), 이름을 비켜서 두면 앞의 것과 같은 선택자로 md-sys 칸을 덮어 묶음 순서가
112
+ 색을 정한다. md-sys 슬롯은 모듈이 선언하지 않는다(ADR-0060 결정 2).
113
+
114
+ 폴더가 `themes` 가 아니면 플러그인이 아예 보지 않는다. 그것이 이 폴더 이름이 하는 일이므로,
115
+ 누가 되돌려 놓으면 여기서 빨강이 된다.
116
+ */
117
+ test('figure-ui 는 client/themes 를 갖지 않는다 — 플러그인이 보는 자리다', () => {
118
+ const at = join(HERE, '..', 'client', 'themes')
119
+
120
+ assert.ok(
121
+ !existsSync(at),
122
+ `client/themes 가 다시 생겼다. 테마 묶음이 이 파일들을 집어 앱 색을 순서로 정하게 된다: ${at}`
98
123
  )
124
+ })
99
125
 
100
- assert.deepEqual(own, [], `하네스가 색을 스스로 정한다: ${own.join(' · ')}`)
126
+ /*
127
+ `copy:files` 는 `copyfiles` 이고, 그것은 **지운 파일을 지우지 않는다.** `build:client` 도 clean 을 하지 않는다
128
+ (`copy:files && tsc` 뿐). 그래서 소스에서 css 를 옮기거나 지워도 산출물에는 옛 파일이 남고, 테마 묶음을 고르는
129
+ `ThemeOverridePlugin` 은 **산출물**을 glob 하므로 그 옛 파일을 계속 집는다.
130
+
131
+ 이 폴더를 옮길 때 실제로 그랬다 — 소스는 `harness-theme` 인데 `dist-client/themes/` 의 세 파일이 그대로 남아
132
+ 있었다. 위의 시험들은 소스를 읽으니 전부 초록이었다. UX 레인이 warehouse 에서 같은 것을 밟았다(화면이 옛 색
133
+ 그대로였고 시험은 초록이었다).
134
+
135
+ 그래서 산출물에만 있는 css 를 여기서 잡는다. 산출물이 아직 없으면 볼 것이 없다 — 그때는 건너뛴다.
136
+ */
137
+ test('산출물에 소스에 없는 css 가 없다 — copyfiles 는 지운 것을 지우지 않는다', () => {
138
+ const source = join(HERE, '..', 'client')
139
+ const built = join(HERE, '..', 'dist-client')
140
+
141
+ if (!existsSync(built)) return
142
+
143
+ const cssUnder = (root: string): string[] => {
144
+ const found: string[] = []
145
+ const walk = (at: string) => {
146
+ for (const entry of readdirSync(at)) {
147
+ const full = join(at, entry)
148
+ if (statSync(full).isDirectory()) walk(full)
149
+ else if (entry.endsWith('.css')) found.push(relative(root, full))
150
+ }
151
+ }
152
+ walk(root)
153
+ return found.sort()
154
+ }
155
+
156
+ const orphans = cssUnder(built).filter(at => !existsSync(join(source, at)))
157
+
158
+ assert.deepEqual(
159
+ orphans,
160
+ [],
161
+ `산출물에만 있는 css: ${orphans.join(' · ')}\n` +
162
+ `소스에서 옮기거나 지운 것이 산출물에 남아 있다. 테마 묶음은 산출물을 보므로 이것이 계속 실린다.\n` +
163
+ ` rm -rf packages/figure-ui/dist-client && npm run build:client`
164
+ )
165
+ })
166
+
167
+ /*
168
+ 옮긴 자리를 하네스가 가리키지 않으면 하네스는 스타일시트 없이 뜬다 — 오류 없이 무채색 화면이
169
+ 되고, 아래 대비 시험들은 **어느 화면에도 없는 값**을 재게 된다.
170
+ */
171
+ test('하네스 둘이 옮긴 팔레트를 가리킨다 — 밝은 것과 어두운 것 모두', () => {
172
+ for (const path of HARNESSES) {
173
+ const html = read(path)
174
+
175
+ for (const file of ['light.css', 'dark.css']) {
176
+ assert.ok(
177
+ html.includes(`/pkg/figure-ui/dist-client/${FOLDER}/${file}`),
178
+ `${path} 가 ${FOLDER}/${file} 를 싣지 않는다`
179
+ )
180
+ }
181
+ }
101
182
  })
102
183
 
103
184
  test('밝은 면과 어두운 면을 함께 든다 — 한쪽만 있으면 어두운 화면이 안 바뀐다', () => {
104
185
  const brand = read(BRAND)
105
186
 
106
- assert.ok(definitions(brand).has('--brand-figure'), '밝은 면용이 없다')
107
- assert.ok(definitions(brand).has('--brand-figure-2'), '어두운 면용이 없다')
187
+ assert.ok(definitions(brand).has('--figure-brand'), '밝은 면용이 없다')
188
+ assert.ok(definitions(brand).has('--figure-brand-2'), '어두운 면용이 없다')
108
189
  })
109
190
 
110
191
  test('브랜드 색 위의 흰 글자가 읽힌다 — 작은 글자 기준 4.5', () => {
111
- const figure = colorOf(read(BRAND), '--brand-figure')
192
+ const figure = colorOf(read(BRAND), '--figure-brand')
112
193
  const measured = contrast(figure, '#ffffff')
113
194
 
114
195
  assert.ok(measured >= 4.5, `${figure} 위 흰 글자 대비가 ${measured.toFixed(2)} 다`)
115
196
  })
116
197
 
117
198
  test('어두운 면 위의 브랜드 색이 읽힌다 — 그쪽은 -2 가 맡는다', () => {
118
- const bright = colorOf(read(BRAND), '--brand-figure-2')
199
+ const bright = colorOf(read(BRAND), '--figure-brand-2')
119
200
  const surface = colorOf(read(THEMES[1]!), '--md-sys-color-surface')
120
201
  const measured = contrast(bright, surface)
121
202
 
@@ -124,9 +205,9 @@ test('어두운 면 위의 브랜드 색이 읽힌다 — 그쪽은 -2 가 맡
124
205
 
125
206
  test('밝은 쪽 값을 어두운 면에 쓰지 않는다 — 이름이 뜻이고 값은 자리에 따라 다르다', () => {
126
207
  /*
127
- * `--brand-figure` 를 어두운 면에 그대로 쓰면 대비 1.6 이라 사라진다. 그 실수를 잡는다.
208
+ * `--figure-brand` 를 어두운 면에 그대로 쓰면 대비 1.6 이라 사라진다. 그 실수를 잡는다.
128
209
  */
129
- const figure = colorOf(read(BRAND), '--brand-figure')
210
+ const figure = colorOf(read(BRAND), '--figure-brand')
130
211
  const darkSurface = colorOf(read(THEMES[1]!), '--md-sys-color-surface')
131
212
 
132
213
  assert.ok(
@@ -135,7 +216,7 @@ test('밝은 쪽 값을 어두운 면에 쓰지 않는다 — 이름이 뜻이
135
216
  )
136
217
 
137
218
  const dark = read(THEMES[1]!)
138
- assert.match(dark, /--md-sys-color-primary:\s*var\(--brand-figure-2\)/, '어두운 테마가 -2 를 안 쓴다')
219
+ assert.match(dark, /--md-sys-color-primary:\s*var\(--figure-brand-2\)/, '어두운 테마가 -2 를 안 쓴다')
139
220
  })
140
221
 
141
222
  test('글자 셋이 다 읽힌다 — 흐린 것까지', () => {
package/test/expect.ts CHANGED
@@ -29,6 +29,7 @@ export interface Expectation {
29
29
  toBeUndefined(): void
30
30
  toBeGreaterThan(expected: number): void
31
31
  toBeLessThan(expected: number): void
32
+ toBeGreaterThanOrEqual(expected: number): void
32
33
  toBeLessThanOrEqual(expected: number): void
33
34
  }
34
35
 
@@ -50,6 +51,7 @@ export function expect(actual: any): Expectation {
50
51
  toBeUndefined: () => assert.strictEqual(actual, undefined),
51
52
  toBeGreaterThan: expected => assert.ok(actual > expected, `${actual} is not greater than ${expected}`),
52
53
  toBeLessThan: expected => assert.ok(actual < expected, `${actual} is not less than ${expected}`),
54
+ toBeGreaterThanOrEqual: expected => assert.ok(actual >= expected, `${actual} is not >= ${expected}`),
53
55
  toBeLessThanOrEqual: expected => assert.ok(actual <= expected, `${actual} is not <= ${expected}`)
54
56
  }
55
57
  }
@@ -105,6 +105,23 @@ test('통째로 부르는 키가 번역 파일에 있다 — 없으면 화면에
105
105
  assert.deepEqual(missing, [], `부르는 키가 번역에 없다:\n ${missing.join('\n ')}`)
106
106
  })
107
107
 
108
+ test('★ 한국어를 defaultValue 로 들고 있지 않다 — 번역 파일을 지나지 않는 문구가 된다', () => {
109
+ const hard: string[] = []
110
+ for (const path of FILES) {
111
+ const text = readFileSync(path, 'utf-8')
112
+ for (const match of text.matchAll(/defaultValue:\s*(['"`])([^'"`]*)\1/g)) {
113
+ if (/[가-힣]/.test(match[2]!)) hard.push(`${path.slice(CLIENT.length + 1)} ${match[2]}`)
114
+ }
115
+ }
116
+
117
+ /*
118
+ `t(key, { defaultValue })` 는 키가 없을 때 그 값을 그대로 쓴다. 한국어를 적어 두면 다른
119
+ 언어에서도 그 한국어가 뜨고, 번역 파일에 키를 넣어도 아무도 그 자리를 다시 안 본다.
120
+ ADR-0049 ②.
121
+ */
122
+ assert.deepEqual(hard, [], `한국어 defaultValue:\n ${hard.join('\n ')}`)
123
+ })
124
+
108
125
  /*
109
126
  이어 붙이는 자리는 값을 코드에서 읽어야 알 수 있어 여기서 전수 검사하지 않는다. 대신 **통이
110
127
  갈린 짝**을 못 박는다 — 사다리 이름은 `label`, 그 설명은 `text` 다. 한 번 어긋났고, 그때
@@ -0,0 +1,162 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { readFileSync } from 'node:fs'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ import { compile, validate } from '@hatiolab/figure-model'
8
+ import type { FigureSource } from '@hatiolab/figure-model'
9
+
10
+ import { jointNeedles, NEEDLE } from '../client/modeller/joint-gizmo.ts'
11
+
12
+ /*
13
+ * 미리보기에서 관절이 돌았다는 것을 보이게 하는 바늘.
14
+ *
15
+ * 이 시험이 지키는 것은 하나다 — **축에서 비켜나 있을 것.** 축 위에 붙으면 대칭인 부품과 같이
16
+ * 돌면서 같은 그림이 되고, 그러면 바늘을 붙인 뜻이 없어진다. 없는 것보다 나쁘다: 화면에 무엇이
17
+ * 하나 더 생겼는데 여전히 아무것도 안 움직인다.
18
+ */
19
+
20
+ const source = (over: Partial<FigureSource> = {}): FigureSource => ({
21
+ version: 2,
22
+ type: 'ARM',
23
+ base: { x: 400, y: 1200, z: 400 },
24
+ placement: 'floor',
25
+ detailLevel: 'M',
26
+ parts: [
27
+ {
28
+ name: 'post',
29
+ primitive: 'cylinder',
30
+ transform: { position: { x: 0, y: 100, z: 0 }, size: { x: 200, y: 200, z: 200 } },
31
+ material: { token: 'palette.dark' }
32
+ },
33
+ {
34
+ name: 'arm',
35
+ primitive: 'cylinder',
36
+ transform: { position: { x: 0, y: 400, z: 0 }, size: { x: 120, y: 400, z: 120 } },
37
+ material: { token: 'palette.primary' },
38
+ parent: 'post'
39
+ }
40
+ ],
41
+ joints: [
42
+ {
43
+ name: 'turn',
44
+ child: 'arm',
45
+ type: 'revolute',
46
+ origin: { x: 0, y: 200, z: 0 },
47
+ axis: { x: 0, y: 1, z: 0 },
48
+ limits: { min: -90, max: 90 }
49
+ }
50
+ ],
51
+ ...over
52
+ })
53
+
54
+ describe('관절 바늘', () => {
55
+ it('관절이 모는 부품에 자식으로 붙는다 — 그래야 관절이 도는 만큼 같이 돈다', () => {
56
+ const [needle] = jointNeedles(source())
57
+
58
+ assert.equal(needle!.parent, 'arm')
59
+ assert.equal(needle!.name, `${NEEDLE}turn`)
60
+ })
61
+
62
+ it('축에서 비켜나 선다 — 축 위에 있으면 대칭인 부품과 같은 그림이다', () => {
63
+ const [needle] = jointNeedles(source())
64
+ const { position, size } = needle!.transform
65
+
66
+ /* y 축으로 도는 관절: 바늘은 x 로 비켜나고 x 로 길다. */
67
+ assert.ok(position.x > 0, '축에서 비켜났다')
68
+ assert.equal(position.y, 200, '기준점 높이 그대로')
69
+ assert.ok(size.x > size.y && size.x > size.z, 'x 로 길다')
70
+ })
71
+
72
+ it('가로축으로 도는 관절은 z 로 비켜난다', () => {
73
+ const [needle] = jointNeedles(source({ joints: [{ ...source().joints![0]!, axis: { x: 1, y: 0, z: 0 } }] }))
74
+ const { position, size } = needle!.transform
75
+
76
+ assert.ok(position.z > 0)
77
+ assert.ok(size.z > size.x && size.z > size.y)
78
+ })
79
+
80
+ it('세로축(z)으로 도는 관절은 x 로 비켜난다', () => {
81
+ const [needle] = jointNeedles(source({ joints: [{ ...source().joints![0]!, axis: { x: 0, y: 0, z: 1 } }] }))
82
+ assert.ok(needle!.transform.position.x > 0)
83
+ })
84
+
85
+ it('비스듬한 축은 그리지 않는다 — 비켜날 쪽을 못 고른다', () => {
86
+ const oblique = source({ joints: [{ ...source().joints![0]!, axis: { x: 1, y: 1, z: 0 } }] })
87
+ assert.deepEqual(jointNeedles(oblique), [])
88
+ })
89
+
90
+ it('길이는 도는 부품에서 온다. 작은 부품에서도 보일 만큼은 된다', () => {
91
+ const [big] = jointNeedles(source())
92
+ assert.equal(big!.transform.size.x, Math.round(400 * 0.6))
93
+
94
+ const small = source({
95
+ parts: source().parts.map(part =>
96
+ part.name === 'arm'
97
+ ? { ...part, transform: { ...part.transform, size: { x: 10, y: 10, z: 10 } } }
98
+ : part
99
+ )
100
+ })
101
+ assert.equal(jointNeedles(small)[0]!.transform.size.x, 60)
102
+ })
103
+
104
+ it('굵기는 길이에 견준다 — 8mm 로 고정했더니 미리보기에서 한 픽셀이 안 됐다', () => {
105
+ const [needle] = jointNeedles(source())
106
+ const { size } = needle!.transform
107
+
108
+ /* 길이 240 짜리 막대는 48 로 두껍다. 부품이 작아도 6 밑으로는 안 내려간다. */
109
+ assert.equal(size.y, Math.round(size.x / 5))
110
+ assert.equal(size.z, size.y)
111
+
112
+ const small = source({
113
+ parts: source().parts.map(part =>
114
+ part.name === 'arm' ? { ...part, transform: { ...part.transform, size: { x: 10, y: 10, z: 10 } } } : part
115
+ )
116
+ })
117
+ assert.equal(jointNeedles(small)[0]!.transform.size.y, Math.max(6, Math.round(60 / 5)))
118
+ })
119
+
120
+ it('관절이 없으면 바늘도 없다', () => {
121
+ assert.deepEqual(jointNeedles(source({ joints: [] })), [])
122
+ assert.deepEqual(jointNeedles(source({ joints: undefined })), [])
123
+ })
124
+
125
+ it('바늘을 붙인 형식이 그대로 선다 — 미리보기가 이것을 compile 한다', () => {
126
+ const shown: FigureSource = { ...source(), parts: [...source().parts, ...jointNeedles(source())] }
127
+
128
+ assert.deepEqual(validate(shown).errors, [])
129
+ assert.doesNotThrow(() => compile(shown))
130
+ })
131
+ })
132
+
133
+ /*
134
+ * 바늘은 **미리보기에만** 있다. 저장 형식으로 새어 나가면 발행된 도형에 없던 막대가 생기고,
135
+ * 계약 판정에서는 부품이 하나 늘어난 것으로 읽힌다.
136
+ */
137
+ describe('바늘이 미리보기 밖으로 안 나간다', () => {
138
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
139
+
140
+ it('미리보기가 붙여서 세운다', () => {
141
+ const preview = read('../client/modeller/figure-preview.ts')
142
+
143
+ assert.ok(/jointNeedles\(source\)/.test(preview), '붙인다')
144
+ assert.ok(/registerFigure\(compile\(shown\)/.test(preview), '붙인 것을 세운다')
145
+ })
146
+
147
+ it('저작면과 저장은 원본 그대로다', () => {
148
+ const page = read('../client/pages/figure-modeller-page.ts')
149
+ const canvas = read('../client/modeller/figure-canvas.ts')
150
+
151
+ assert.ok(!/jointNeedles/.test(page), '페이지는 바늘을 모른다')
152
+ assert.ok(!/jointNeedles/.test(canvas), '저작 캔버스는 바늘을 모른다')
153
+ })
154
+
155
+ it('원본 목록을 건드리지 않는다 — 같은 배열에 밀어 넣으면 저장에 들어간다', () => {
156
+ const original = source()
157
+ const before = original.parts.length
158
+ jointNeedles(original)
159
+
160
+ assert.equal(original.parts.length, before)
161
+ })
162
+ })