@tldraw/tlschema 4.2.2 → 4.2.3

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 (117) hide show
  1. package/dist-cjs/bindings/TLBaseBinding.js.map +2 -2
  2. package/dist-cjs/createTLSchema.js.map +2 -2
  3. package/dist-cjs/index.d.ts +71 -242
  4. package/dist-cjs/index.js +1 -4
  5. package/dist-cjs/index.js.map +2 -2
  6. package/dist-cjs/misc/TLOpacity.js +5 -1
  7. package/dist-cjs/misc/TLOpacity.js.map +2 -2
  8. package/dist-cjs/misc/TLRichText.js +1 -5
  9. package/dist-cjs/misc/TLRichText.js.map +2 -2
  10. package/dist-cjs/records/TLAsset.js.map +1 -1
  11. package/dist-cjs/records/TLBinding.js.map +2 -2
  12. package/dist-cjs/records/TLShape.js.map +2 -2
  13. package/dist-cjs/shapes/ShapeWithCrop.js.map +1 -1
  14. package/dist-cjs/shapes/TLArrowShape.js +13 -26
  15. package/dist-cjs/shapes/TLArrowShape.js.map +2 -2
  16. package/dist-cjs/shapes/TLBaseShape.js.map +2 -2
  17. package/dist-cjs/shapes/TLDrawShape.js +4 -37
  18. package/dist-cjs/shapes/TLDrawShape.js.map +2 -2
  19. package/dist-cjs/shapes/TLEmbedShape.js +0 -17
  20. package/dist-cjs/shapes/TLEmbedShape.js.map +2 -2
  21. package/dist-cjs/shapes/TLGeoShape.js +1 -12
  22. package/dist-cjs/shapes/TLGeoShape.js.map +2 -2
  23. package/dist-cjs/shapes/TLHighlightShape.js +2 -29
  24. package/dist-cjs/shapes/TLHighlightShape.js.map +2 -2
  25. package/dist-cjs/shapes/TLNoteShape.js +1 -12
  26. package/dist-cjs/shapes/TLNoteShape.js.map +2 -2
  27. package/dist-cjs/shapes/TLTextShape.js +1 -12
  28. package/dist-cjs/shapes/TLTextShape.js.map +2 -2
  29. package/dist-cjs/store-migrations.js +15 -15
  30. package/dist-cjs/store-migrations.js.map +2 -2
  31. package/dist-esm/bindings/TLBaseBinding.mjs.map +2 -2
  32. package/dist-esm/createTLSchema.mjs.map +2 -2
  33. package/dist-esm/index.d.mts +71 -242
  34. package/dist-esm/index.mjs +1 -5
  35. package/dist-esm/index.mjs.map +2 -2
  36. package/dist-esm/misc/TLOpacity.mjs +5 -1
  37. package/dist-esm/misc/TLOpacity.mjs.map +2 -2
  38. package/dist-esm/misc/TLRichText.mjs +1 -5
  39. package/dist-esm/misc/TLRichText.mjs.map +2 -2
  40. package/dist-esm/records/TLAsset.mjs.map +1 -1
  41. package/dist-esm/records/TLBinding.mjs.map +2 -2
  42. package/dist-esm/records/TLShape.mjs.map +2 -2
  43. package/dist-esm/shapes/TLArrowShape.mjs +13 -26
  44. package/dist-esm/shapes/TLArrowShape.mjs.map +2 -2
  45. package/dist-esm/shapes/TLBaseShape.mjs.map +2 -2
  46. package/dist-esm/shapes/TLDrawShape.mjs +4 -37
  47. package/dist-esm/shapes/TLDrawShape.mjs.map +2 -2
  48. package/dist-esm/shapes/TLEmbedShape.mjs +0 -17
  49. package/dist-esm/shapes/TLEmbedShape.mjs.map +2 -2
  50. package/dist-esm/shapes/TLGeoShape.mjs +1 -12
  51. package/dist-esm/shapes/TLGeoShape.mjs.map +2 -2
  52. package/dist-esm/shapes/TLHighlightShape.mjs +2 -29
  53. package/dist-esm/shapes/TLHighlightShape.mjs.map +2 -2
  54. package/dist-esm/shapes/TLNoteShape.mjs +1 -12
  55. package/dist-esm/shapes/TLNoteShape.mjs.map +2 -2
  56. package/dist-esm/shapes/TLTextShape.mjs +1 -12
  57. package/dist-esm/shapes/TLTextShape.mjs.map +2 -2
  58. package/dist-esm/store-migrations.mjs +15 -15
  59. package/dist-esm/store-migrations.mjs.map +2 -2
  60. package/package.json +8 -8
  61. package/src/__tests__/migrationTestUtils.ts +3 -9
  62. package/src/assets/TLBookmarkAsset.test.ts +96 -0
  63. package/src/assets/TLImageAsset.test.ts +213 -0
  64. package/src/assets/TLVideoAsset.test.ts +105 -0
  65. package/src/bindings/TLArrowBinding.test.ts +55 -0
  66. package/src/bindings/TLBaseBinding.ts +14 -25
  67. package/src/createTLSchema.ts +2 -8
  68. package/src/index.ts +0 -9
  69. package/src/migrations.test.ts +1 -149
  70. package/src/misc/TLOpacity.ts +5 -1
  71. package/src/misc/TLRichText.ts +1 -6
  72. package/src/misc/id-validator.test.ts +50 -0
  73. package/src/records/TLAsset.test.ts +234 -0
  74. package/src/records/TLAsset.ts +2 -2
  75. package/src/records/TLBinding.test.ts +22 -0
  76. package/src/records/TLBinding.ts +23 -65
  77. package/src/records/TLCamera.test.ts +19 -0
  78. package/src/records/TLDocument.test.ts +35 -0
  79. package/src/records/TLInstance.test.ts +201 -0
  80. package/src/records/TLPage.test.ts +110 -0
  81. package/src/records/TLPageState.test.ts +228 -0
  82. package/src/records/TLPointer.test.ts +63 -0
  83. package/src/records/TLPresence.test.ts +190 -0
  84. package/src/records/TLRecord.test.ts +70 -0
  85. package/src/records/TLShape.test.ts +232 -0
  86. package/src/records/TLShape.ts +5 -100
  87. package/src/shapes/ShapeWithCrop.test.ts +18 -0
  88. package/src/shapes/ShapeWithCrop.ts +2 -2
  89. package/src/shapes/TLArrowShape.test.ts +505 -0
  90. package/src/shapes/TLArrowShape.ts +14 -28
  91. package/src/shapes/TLBaseShape.test.ts +142 -0
  92. package/src/shapes/TLBaseShape.ts +10 -34
  93. package/src/shapes/TLBookmarkShape.test.ts +122 -0
  94. package/src/shapes/TLDrawShape.test.ts +177 -0
  95. package/src/shapes/TLDrawShape.ts +12 -59
  96. package/src/shapes/TLEmbedShape.test.ts +286 -0
  97. package/src/shapes/TLEmbedShape.ts +0 -17
  98. package/src/shapes/TLFrameShape.test.ts +71 -0
  99. package/src/shapes/TLGeoShape.test.ts +247 -0
  100. package/src/shapes/TLGeoShape.ts +1 -14
  101. package/src/shapes/TLGroupShape.test.ts +59 -0
  102. package/src/shapes/TLHighlightShape.test.ts +325 -0
  103. package/src/shapes/TLHighlightShape.ts +0 -37
  104. package/src/shapes/TLImageShape.test.ts +534 -0
  105. package/src/shapes/TLLineShape.test.ts +269 -0
  106. package/src/shapes/TLNoteShape.test.ts +1568 -0
  107. package/src/shapes/TLNoteShape.ts +1 -15
  108. package/src/shapes/TLTextShape.test.ts +407 -0
  109. package/src/shapes/TLTextShape.ts +2 -16
  110. package/src/shapes/TLVideoShape.test.ts +112 -0
  111. package/src/store-migrations.ts +16 -17
  112. package/src/styles/TLColorStyle.test.ts +439 -0
  113. package/dist-cjs/misc/b64Vecs.js +0 -224
  114. package/dist-cjs/misc/b64Vecs.js.map +0 -7
  115. package/dist-esm/misc/b64Vecs.mjs +0 -204
  116. package/dist-esm/misc/b64Vecs.mjs.map +0 -7
  117. package/src/misc/b64Vecs.ts +0 -308
@@ -0,0 +1,247 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getTestMigration } from '../__tests__/migrationTestUtils'
3
+ import { toRichText } from '../misc/TLRichText'
4
+ import { GeoShapeGeoStyle, geoShapeProps, geoShapeVersions } from './TLGeoShape'
5
+
6
+ describe('TLGeoShape', () => {
7
+ describe('GeoShapeGeoStyle', () => {
8
+ it('should validate all geometric shape types', () => {
9
+ const validGeoTypes = [
10
+ 'cloud',
11
+ 'rectangle',
12
+ 'ellipse',
13
+ 'triangle',
14
+ 'diamond',
15
+ 'pentagon',
16
+ 'hexagon',
17
+ 'octagon',
18
+ 'star',
19
+ 'rhombus',
20
+ 'rhombus-2',
21
+ 'oval',
22
+ 'trapezoid',
23
+ 'arrow-right',
24
+ 'arrow-left',
25
+ 'arrow-up',
26
+ 'arrow-down',
27
+ 'x-box',
28
+ 'check-box',
29
+ 'heart',
30
+ ]
31
+
32
+ validGeoTypes.forEach((geoType) => {
33
+ expect(() => GeoShapeGeoStyle.validate(geoType)).not.toThrow()
34
+ })
35
+ })
36
+
37
+ it('should reject invalid geometric shape types', () => {
38
+ const invalidGeoTypes = ['square', 'circle', 'invalid-shape', null, undefined]
39
+
40
+ invalidGeoTypes.forEach((geoType) => {
41
+ expect(() => GeoShapeGeoStyle.validate(geoType)).toThrow()
42
+ })
43
+ })
44
+ })
45
+
46
+ describe('geoShapeProps validation schema', () => {
47
+ it('should validate numeric constraints', () => {
48
+ // Test nonZeroNumber validation for w, h, scale
49
+ expect(() => geoShapeProps.w.validate(100)).not.toThrow()
50
+ expect(() => geoShapeProps.h.validate(50)).not.toThrow()
51
+ expect(() => geoShapeProps.scale.validate(1.5)).not.toThrow()
52
+
53
+ expect(() => geoShapeProps.w.validate(0)).toThrow()
54
+ expect(() => geoShapeProps.h.validate(0)).toThrow()
55
+ expect(() => geoShapeProps.scale.validate(0)).toThrow()
56
+
57
+ // Test positiveNumber validation for growY
58
+ expect(() => geoShapeProps.growY.validate(0)).not.toThrow()
59
+ expect(() => geoShapeProps.growY.validate(10)).not.toThrow()
60
+ expect(() => geoShapeProps.growY.validate(-1)).toThrow()
61
+ })
62
+
63
+ it('should validate rich text property', () => {
64
+ expect(() => geoShapeProps.richText.validate(toRichText('test'))).not.toThrow()
65
+ expect(() => geoShapeProps.richText.validate('plain string')).toThrow()
66
+ })
67
+ })
68
+
69
+ describe('geoShapeMigrations - AddUrlProp migration', () => {
70
+ const { up } = getTestMigration(geoShapeVersions.AddUrlProp)
71
+
72
+ it('should add url property with empty string default', () => {
73
+ const oldRecord = {
74
+ props: {
75
+ geo: 'rectangle',
76
+ w: 100,
77
+ h: 80,
78
+ },
79
+ }
80
+
81
+ const result = up(oldRecord)
82
+ expect(result.props.url).toBe('')
83
+ })
84
+ })
85
+
86
+ describe('geoShapeMigrations - AddLabelColor migration', () => {
87
+ const { up } = getTestMigration(geoShapeVersions.AddLabelColor)
88
+
89
+ it('should add labelColor property with default value "black"', () => {
90
+ const oldRecord = {
91
+ props: {
92
+ geo: 'triangle',
93
+ color: 'blue',
94
+ },
95
+ }
96
+
97
+ const result = up(oldRecord)
98
+ expect(result.props.labelColor).toBe('black')
99
+ })
100
+ })
101
+
102
+ describe('geoShapeMigrations - RemoveJustify migration', () => {
103
+ const { up } = getTestMigration(geoShapeVersions.RemoveJustify)
104
+
105
+ it('should convert justify alignment to start', () => {
106
+ const oldRecord = {
107
+ props: {
108
+ align: 'justify',
109
+ },
110
+ }
111
+
112
+ const result = up(oldRecord)
113
+ expect(result.props.align).toBe('start')
114
+ })
115
+
116
+ it('should preserve non-justify alignments', () => {
117
+ const oldRecord = {
118
+ props: {
119
+ align: 'middle',
120
+ },
121
+ }
122
+
123
+ const result = up(oldRecord)
124
+ expect(result.props.align).toBe('middle')
125
+ })
126
+ })
127
+
128
+ describe('geoShapeMigrations - AddVerticalAlign migration', () => {
129
+ const { up } = getTestMigration(geoShapeVersions.AddVerticalAlign)
130
+
131
+ it('should add verticalAlign property with default value "middle"', () => {
132
+ const oldRecord = {
133
+ props: {
134
+ geo: 'rectangle',
135
+ },
136
+ }
137
+
138
+ const result = up(oldRecord)
139
+ expect(result.props.verticalAlign).toBe('middle')
140
+ })
141
+ })
142
+
143
+ describe('geoShapeMigrations - MigrateLegacyAlign migration', () => {
144
+ const { up } = getTestMigration(geoShapeVersions.MigrateLegacyAlign)
145
+
146
+ it('should convert alignment values to legacy versions', () => {
147
+ const testCases = [
148
+ { input: 'start', expected: 'start-legacy' },
149
+ { input: 'end', expected: 'end-legacy' },
150
+ { input: 'middle', expected: 'middle-legacy' },
151
+ { input: 'unknown-align', expected: 'middle-legacy' },
152
+ ]
153
+
154
+ testCases.forEach(({ input, expected }) => {
155
+ const oldRecord = {
156
+ props: {
157
+ align: input,
158
+ },
159
+ }
160
+
161
+ const result = up(oldRecord)
162
+ expect(result.props.align).toBe(expected)
163
+ })
164
+ })
165
+ })
166
+
167
+ describe('geoShapeMigrations - MakeUrlsValid migration', () => {
168
+ const { up } = getTestMigration(geoShapeVersions.MakeUrlsValid)
169
+
170
+ it('should clear invalid URLs', () => {
171
+ const oldRecord = {
172
+ props: {
173
+ url: 'invalid-url',
174
+ },
175
+ }
176
+
177
+ const result = up(oldRecord)
178
+ expect(result.props.url).toBe('')
179
+ })
180
+
181
+ it('should preserve valid URLs', () => {
182
+ const oldRecord = {
183
+ props: {
184
+ url: 'https://example.com',
185
+ },
186
+ }
187
+
188
+ const result = up(oldRecord)
189
+ expect(result.props.url).toBe('https://example.com')
190
+ })
191
+ })
192
+
193
+ describe('geoShapeMigrations - AddScale migration', () => {
194
+ const { up, down } = getTestMigration(geoShapeVersions.AddScale)
195
+
196
+ it('should add scale property with default value 1', () => {
197
+ const oldRecord = {
198
+ props: {
199
+ geo: 'rectangle',
200
+ },
201
+ }
202
+
203
+ const result = up(oldRecord)
204
+ expect(result.props.scale).toBe(1)
205
+ })
206
+
207
+ it('should remove scale property on down migration', () => {
208
+ const newRecord = {
209
+ props: {
210
+ geo: 'rectangle',
211
+ scale: 1.5,
212
+ },
213
+ }
214
+
215
+ const result = down(newRecord)
216
+ expect(result.props.scale).toBeUndefined()
217
+ })
218
+ })
219
+
220
+ describe('geoShapeMigrations - AddRichText migration', () => {
221
+ const { up } = getTestMigration(geoShapeVersions.AddRichText)
222
+
223
+ it('should convert text property to richText', () => {
224
+ const oldRecord = {
225
+ props: {
226
+ text: 'Simple text content',
227
+ },
228
+ }
229
+
230
+ const result = up(oldRecord)
231
+ expect(result.props.richText).toBeDefined()
232
+ expect(result.props.text).toBeUndefined()
233
+ })
234
+
235
+ it('should handle empty text', () => {
236
+ const oldRecord = {
237
+ props: {
238
+ text: '',
239
+ },
240
+ }
241
+
242
+ const result = up(oldRecord)
243
+ expect(result.props.richText).toBeDefined()
244
+ expect(result.props.text).toBeUndefined()
245
+ })
246
+ })
247
+ })
@@ -193,7 +193,6 @@ const geoShapeVersions = createShapePropsMigrationIds('geo', {
193
193
  MakeUrlsValid: 8,
194
194
  AddScale: 9,
195
195
  AddRichText: 10,
196
- AddRichTextAttrs: 11,
197
196
  })
198
197
 
199
198
  /**
@@ -206,7 +205,7 @@ export { geoShapeVersions as geoShapeVersions }
206
205
  /**
207
206
  * Migration sequence for geo shape properties across different schema versions.
208
207
  * Handles evolution of geo shapes including URL support, label colors, alignment changes,
209
- * the transition from plain text to rich text, and support for attrs property on richText.
208
+ * and the transition from plain text to rich text.
210
209
  *
211
210
  * @public
212
211
  */
@@ -306,17 +305,5 @@ export const geoShapeMigrations = createShapePropsMigrationSequence({
306
305
  // delete props.richText
307
306
  // },
308
307
  },
309
- {
310
- id: geoShapeVersions.AddRichTextAttrs,
311
- up: (_props) => {
312
- // noop - attrs is optional so old records are valid
313
- },
314
- down: (props) => {
315
- // Remove attrs from richText when migrating down
316
- if (props.richText && 'attrs' in props.richText) {
317
- delete props.richText.attrs
318
- }
319
- },
320
- },
321
308
  ],
322
309
  })
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createShapeValidator } from './TLBaseShape'
3
+ import { groupShapeMigrations, groupShapeProps } from './TLGroupShape'
4
+
5
+ describe('TLGroupShape', () => {
6
+ describe('groupShapeProps', () => {
7
+ it('should be an empty object', () => {
8
+ expect(groupShapeProps).toEqual({})
9
+ })
10
+ })
11
+
12
+ describe('groupShapeMigrations', () => {
13
+ it('should have empty migration sequence', () => {
14
+ expect(groupShapeMigrations.sequence).toEqual([])
15
+ })
16
+ })
17
+
18
+ describe('group shape validation', () => {
19
+ const groupValidator = createShapeValidator('group', groupShapeProps)
20
+
21
+ it('should validate valid group shapes', () => {
22
+ const validGroup = {
23
+ id: 'shape:test',
24
+ typeName: 'shape',
25
+ type: 'group',
26
+ x: 0,
27
+ y: 0,
28
+ rotation: 0,
29
+ index: 'a1',
30
+ parentId: 'page:main',
31
+ isLocked: false,
32
+ opacity: 1,
33
+ props: {},
34
+ meta: {},
35
+ }
36
+
37
+ expect(() => groupValidator.validate(validGroup)).not.toThrow()
38
+ })
39
+
40
+ it('should reject shapes with non-empty props', () => {
41
+ const invalidGroup = {
42
+ id: 'shape:invalid',
43
+ typeName: 'shape',
44
+ type: 'group',
45
+ x: 0,
46
+ y: 0,
47
+ rotation: 0,
48
+ index: 'a1',
49
+ parentId: 'page:main',
50
+ isLocked: false,
51
+ opacity: 1,
52
+ props: { invalid: 'prop' },
53
+ meta: {},
54
+ }
55
+
56
+ expect(() => groupValidator.validate(invalidGroup)).toThrow()
57
+ })
58
+ })
59
+ })
@@ -0,0 +1,325 @@
1
+ import { T } from '@tldraw/validate'
2
+ import { describe, expect, it, test } from 'vitest'
3
+ import { getTestMigration } from '../__tests__/migrationTestUtils'
4
+ import {
5
+ highlightShapeMigrations,
6
+ highlightShapeProps,
7
+ highlightShapeVersions,
8
+ } from './TLHighlightShape'
9
+
10
+ describe('TLHighlightShape', () => {
11
+ describe('highlightShapeProps validation schema', () => {
12
+ it('should validate valid highlight shape properties', () => {
13
+ const validProps = {
14
+ color: 'yellow' as const,
15
+ size: 'l' as const,
16
+ segments: [
17
+ {
18
+ type: 'free' as const,
19
+ points: [{ x: 0, y: 0, z: 0.5 }],
20
+ },
21
+ ],
22
+ isComplete: true,
23
+ isPen: false,
24
+ scale: 1,
25
+ }
26
+
27
+ const fullValidator = T.object(highlightShapeProps)
28
+ expect(() => fullValidator.validate(validProps)).not.toThrow()
29
+ })
30
+
31
+ it('should reject invalid color values', () => {
32
+ const invalidColors = ['purple', 'YELLOW', 'neon', '', null, undefined, 123, {}, []]
33
+
34
+ invalidColors.forEach((color) => {
35
+ expect(() => highlightShapeProps.color.validate(color)).toThrow()
36
+ })
37
+ })
38
+
39
+ it('should reject invalid size values', () => {
40
+ const invalidSizes = ['medium', 'SM', 'large', 'xxl', '', null, undefined, 123]
41
+
42
+ invalidSizes.forEach((size) => {
43
+ expect(() => highlightShapeProps.size.validate(size)).toThrow()
44
+ })
45
+ })
46
+
47
+ it('should validate segments array', () => {
48
+ const validSegmentArrays = [
49
+ [], // Empty array
50
+ [
51
+ {
52
+ type: 'free' as const,
53
+ points: [{ x: 0, y: 0, z: 0.5 }],
54
+ },
55
+ ],
56
+ ]
57
+
58
+ validSegmentArrays.forEach((segments) => {
59
+ expect(() => highlightShapeProps.segments.validate(segments)).not.toThrow()
60
+ })
61
+
62
+ const invalidSegmentArrays = [
63
+ 'not-array',
64
+ null,
65
+ undefined,
66
+ [{ type: 'invalid', points: [] }], // Invalid segment type
67
+ ]
68
+
69
+ invalidSegmentArrays.forEach((segments) => {
70
+ expect(() => highlightShapeProps.segments.validate(segments)).toThrow()
71
+ })
72
+ })
73
+
74
+ it('should validate boolean properties', () => {
75
+ // Valid boolean values
76
+ expect(() => highlightShapeProps.isComplete.validate(true)).not.toThrow()
77
+ expect(() => highlightShapeProps.isComplete.validate(false)).not.toThrow()
78
+ expect(() => highlightShapeProps.isPen.validate(true)).not.toThrow()
79
+ expect(() => highlightShapeProps.isPen.validate(false)).not.toThrow()
80
+
81
+ // Invalid boolean values
82
+ const invalidBooleans = ['true', 'false', 1, 0, null, undefined, {}, []]
83
+ invalidBooleans.forEach((value) => {
84
+ expect(() => highlightShapeProps.isComplete.validate(value)).toThrow()
85
+ expect(() => highlightShapeProps.isPen.validate(value)).toThrow()
86
+ })
87
+ })
88
+
89
+ it('should validate scale as nonZeroNumber', () => {
90
+ // Valid non-zero positive numbers
91
+ expect(() => highlightShapeProps.scale.validate(0.1)).not.toThrow()
92
+ expect(() => highlightShapeProps.scale.validate(1)).not.toThrow()
93
+ expect(() => highlightShapeProps.scale.validate(2)).not.toThrow()
94
+
95
+ // Invalid scales (zero, negative, and non-numbers)
96
+ expect(() => highlightShapeProps.scale.validate(0)).toThrow()
97
+ expect(() => highlightShapeProps.scale.validate(-1)).toThrow()
98
+ expect(() => highlightShapeProps.scale.validate('not-number')).toThrow()
99
+ expect(() => highlightShapeProps.scale.validate(null)).toThrow()
100
+ })
101
+
102
+ it('should reject objects with missing properties', () => {
103
+ const fullValidator = T.object(highlightShapeProps)
104
+
105
+ expect(() => fullValidator.validate({})).toThrow()
106
+ expect(() => fullValidator.validate({ color: 'yellow' })).toThrow()
107
+ })
108
+
109
+ it('should reject objects with extra properties', () => {
110
+ const fullValidator = T.object(highlightShapeProps)
111
+
112
+ const objectWithExtraProps = {
113
+ color: 'yellow',
114
+ size: 'm',
115
+ segments: [],
116
+ isComplete: true,
117
+ isPen: false,
118
+ scale: 1,
119
+ extraProp: 'extra',
120
+ }
121
+
122
+ expect(() => fullValidator.validate(objectWithExtraProps)).toThrow()
123
+ })
124
+ })
125
+
126
+ describe('highlightShapeVersions', () => {
127
+ it('should contain expected migration version IDs', () => {
128
+ expect(highlightShapeVersions).toBeDefined()
129
+ expect(typeof highlightShapeVersions).toBe('object')
130
+ })
131
+
132
+ it('should have all expected migration versions', () => {
133
+ const expectedVersions: Array<keyof typeof highlightShapeVersions> = ['AddScale']
134
+
135
+ expectedVersions.forEach((version) => {
136
+ expect(highlightShapeVersions[version]).toBeDefined()
137
+ expect(typeof highlightShapeVersions[version]).toBe('string')
138
+ })
139
+ })
140
+
141
+ it('should have properly formatted migration IDs', () => {
142
+ Object.values(highlightShapeVersions).forEach((versionId) => {
143
+ expect(versionId).toMatch(/^com\.tldraw\.shape\.highlight\//)
144
+ expect(versionId).toMatch(/\/\d+$/) // Should end with /number
145
+ })
146
+ })
147
+ })
148
+
149
+ describe('highlightShapeMigrations', () => {
150
+ it('should be defined and have required structure', () => {
151
+ expect(highlightShapeMigrations).toBeDefined()
152
+ expect(highlightShapeMigrations.sequence).toBeDefined()
153
+ expect(Array.isArray(highlightShapeMigrations.sequence)).toBe(true)
154
+ })
155
+
156
+ it('should have migrations for all version IDs', () => {
157
+ const migrationIds = highlightShapeMigrations.sequence
158
+ .filter((migration) => 'id' in migration)
159
+ .map((migration) => ('id' in migration ? migration.id : null))
160
+ .filter(Boolean)
161
+
162
+ const versionIds = Object.values(highlightShapeVersions)
163
+
164
+ versionIds.forEach((versionId) => {
165
+ expect(migrationIds).toContain(versionId)
166
+ })
167
+ })
168
+ })
169
+
170
+ describe('highlightShapeMigrations - AddScale migration', () => {
171
+ const { up, down } = getTestMigration(highlightShapeVersions.AddScale)
172
+
173
+ describe('AddScale up migration', () => {
174
+ it('should add scale property with default value 1', () => {
175
+ const oldRecord = {
176
+ id: 'shape:highlight1',
177
+ props: {
178
+ color: 'yellow',
179
+ size: 'm',
180
+ segments: [
181
+ {
182
+ type: 'free',
183
+ points: [{ x: 0, y: 0, z: 0.5 }],
184
+ },
185
+ ],
186
+ isComplete: true,
187
+ isPen: false,
188
+ },
189
+ }
190
+
191
+ const result = up(oldRecord)
192
+ expect(result.props.scale).toBe(1)
193
+ })
194
+
195
+ it('should preserve existing properties during migration', () => {
196
+ const oldRecord = {
197
+ id: 'shape:highlight1',
198
+ props: {
199
+ color: 'green',
200
+ size: 'l',
201
+ segments: [
202
+ {
203
+ type: 'straight',
204
+ points: [
205
+ { x: 10, y: 20 },
206
+ { x: 100, y: 150 },
207
+ ],
208
+ },
209
+ ],
210
+ isComplete: true,
211
+ isPen: true,
212
+ },
213
+ }
214
+
215
+ const result = up(oldRecord)
216
+ expect(result.props.scale).toBe(1)
217
+ expect(result.props.color).toBe('green')
218
+ expect(result.props.size).toBe('l')
219
+ expect(result.props.segments).toEqual(oldRecord.props.segments)
220
+ expect(result.props.isComplete).toBe(true)
221
+ expect(result.props.isPen).toBe(true)
222
+ })
223
+ })
224
+
225
+ describe('AddScale down migration', () => {
226
+ it('should remove scale property', () => {
227
+ const newRecord = {
228
+ id: 'shape:highlight1',
229
+ props: {
230
+ color: 'yellow',
231
+ size: 'm',
232
+ segments: [
233
+ {
234
+ type: 'free',
235
+ points: [{ x: 0, y: 0, z: 0.5 }],
236
+ },
237
+ ],
238
+ isComplete: true,
239
+ isPen: false,
240
+ scale: 1.5,
241
+ },
242
+ }
243
+
244
+ const result = down(newRecord)
245
+ expect(result.props.scale).toBeUndefined()
246
+ expect(result.props.color).toBe('yellow') // Preserve other props
247
+ expect(result.props.isPen).toBe(false) // Preserve other props
248
+ })
249
+
250
+ it('should preserve all other properties during down migration', () => {
251
+ const newRecord = {
252
+ id: 'shape:highlight1',
253
+ props: {
254
+ color: 'red',
255
+ size: 'xl',
256
+ segments: [
257
+ {
258
+ type: 'straight',
259
+ points: [
260
+ { x: 0, y: 0 },
261
+ { x: 200, y: 0 },
262
+ ],
263
+ },
264
+ ],
265
+ isComplete: true,
266
+ isPen: false,
267
+ scale: 2.0,
268
+ },
269
+ }
270
+
271
+ const result = down(newRecord)
272
+ expect(result.props.scale).toBeUndefined()
273
+ expect(result.props.color).toBe('red')
274
+ expect(result.props.size).toBe('xl')
275
+ expect(result.props.segments).toEqual(newRecord.props.segments)
276
+ expect(result.props.isComplete).toBe(true)
277
+ expect(result.props.isPen).toBe(false)
278
+ })
279
+ })
280
+
281
+ it('should support round-trip migration (up then down)', () => {
282
+ const originalRecord = {
283
+ id: 'shape:highlight1',
284
+ props: {
285
+ color: 'green',
286
+ size: 'l',
287
+ segments: [
288
+ {
289
+ type: 'free',
290
+ points: [{ x: 0, y: 0, z: 0.5 }],
291
+ },
292
+ ],
293
+ isComplete: true,
294
+ isPen: true,
295
+ },
296
+ }
297
+
298
+ // Apply up migration
299
+ const upResult = up(originalRecord)
300
+ expect(upResult.props.scale).toBe(1)
301
+
302
+ // Apply down migration
303
+ const downResult = down(upResult)
304
+ expect(downResult.props.scale).toBeUndefined()
305
+ expect(downResult.props.color).toBe('green')
306
+ expect(downResult.props.size).toBe('l')
307
+ expect(downResult.props.isComplete).toBe(true)
308
+ expect(downResult.props.isPen).toBe(true)
309
+ })
310
+ })
311
+
312
+ test('should handle all migration versions in correct order', () => {
313
+ const expectedOrder: Array<keyof typeof highlightShapeVersions> = ['AddScale']
314
+
315
+ const migrationIds = highlightShapeMigrations.sequence
316
+ .filter((migration) => 'id' in migration)
317
+ .map((migration) => ('id' in migration ? migration.id : ''))
318
+ .filter(Boolean)
319
+
320
+ expectedOrder.forEach((expectedVersion) => {
321
+ const versionId = highlightShapeVersions[expectedVersion]
322
+ expect(migrationIds).toContain(versionId)
323
+ })
324
+ })
325
+ })