prosereflect 0.2.0 → 0.3.0

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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/docs.yml +63 -0
  3. data/.github/workflows/links.yml +97 -0
  4. data/.gitignore +4 -0
  5. data/.rubocop_todo.yml +61 -75
  6. data/README.adoc +2 -0
  7. data/docs/Gemfile +10 -0
  8. data/docs/INDEX.adoc +45 -0
  9. data/docs/_advanced/index.adoc +15 -0
  10. data/docs/_advanced/schema.adoc +112 -0
  11. data/docs/_advanced/step-map.adoc +66 -0
  12. data/docs/_advanced/steps.adoc +88 -0
  13. data/docs/_advanced/test-builder.adoc +61 -0
  14. data/docs/_advanced/transform.adoc +92 -0
  15. data/docs/_config.yml +174 -0
  16. data/docs/_features/html-input.adoc +69 -0
  17. data/docs/_features/html-output.adoc +45 -0
  18. data/docs/_features/index.adoc +15 -0
  19. data/docs/_features/marks.adoc +86 -0
  20. data/docs/_features/node-types.adoc +124 -0
  21. data/docs/_features/user-mentions.adoc +47 -0
  22. data/docs/_guides/custom-nodes.adoc +107 -0
  23. data/docs/_guides/index.adoc +13 -0
  24. data/docs/_guides/round-trip-html.adoc +91 -0
  25. data/docs/_guides/serialization.adoc +109 -0
  26. data/docs/_pages/index.adoc +67 -0
  27. data/docs/_reference/document-api.adoc +49 -0
  28. data/docs/_reference/index.adoc +14 -0
  29. data/docs/_reference/node-api.adoc +79 -0
  30. data/docs/_reference/schema-api.adoc +95 -0
  31. data/docs/_reference/transform-api.adoc +77 -0
  32. data/docs/_understanding/document-model.adoc +65 -0
  33. data/docs/_understanding/fragment.adoc +52 -0
  34. data/docs/_understanding/index.adoc +14 -0
  35. data/docs/_understanding/resolved-position.adoc +53 -0
  36. data/docs/_understanding/slice.adoc +54 -0
  37. data/docs/lychee.toml +63 -0
  38. data/lib/prosereflect/blockquote.rb +9 -0
  39. data/lib/prosereflect/bullet_list.rb +25 -19
  40. data/lib/prosereflect/code_block.rb +1 -5
  41. data/lib/prosereflect/fragment.rb +249 -0
  42. data/lib/prosereflect/horizontal_rule.rb +9 -0
  43. data/lib/prosereflect/image.rb +9 -0
  44. data/lib/prosereflect/input/html.rb +96 -0
  45. data/lib/prosereflect/node.rb +141 -3
  46. data/lib/prosereflect/ordered_list.rb +2 -0
  47. data/lib/prosereflect/output/html.rb +227 -0
  48. data/lib/prosereflect/parser.rb +9 -0
  49. data/lib/prosereflect/resolved_pos.rb +256 -0
  50. data/lib/prosereflect/schema/attribute.rb +57 -0
  51. data/lib/prosereflect/schema/content_match.rb +656 -0
  52. data/lib/prosereflect/schema/fragment.rb +166 -0
  53. data/lib/prosereflect/schema/mark.rb +121 -0
  54. data/lib/prosereflect/schema/mark_type.rb +130 -0
  55. data/lib/prosereflect/schema/node.rb +236 -0
  56. data/lib/prosereflect/schema/node_type.rb +274 -0
  57. data/lib/prosereflect/schema/schema_main.rb +190 -0
  58. data/lib/prosereflect/schema/spec.rb +92 -0
  59. data/lib/prosereflect/schema.rb +39 -0
  60. data/lib/prosereflect/text.rb +24 -0
  61. data/lib/prosereflect/transform/attr_step.rb +157 -0
  62. data/lib/prosereflect/transform/insert_step.rb +115 -0
  63. data/lib/prosereflect/transform/mapping.rb +82 -0
  64. data/lib/prosereflect/transform/mark_step.rb +269 -0
  65. data/lib/prosereflect/transform/replace_around_step.rb +181 -0
  66. data/lib/prosereflect/transform/replace_step.rb +157 -0
  67. data/lib/prosereflect/transform/slice.rb +91 -0
  68. data/lib/prosereflect/transform/step.rb +89 -0
  69. data/lib/prosereflect/transform/step_map.rb +126 -0
  70. data/lib/prosereflect/transform/structure.rb +120 -0
  71. data/lib/prosereflect/transform/transform.rb +341 -0
  72. data/lib/prosereflect/transform.rb +26 -0
  73. data/lib/prosereflect/version.rb +1 -1
  74. data/lib/prosereflect.rb +3 -0
  75. data/spec/fixtures/documents/formatted_text.yaml +14 -0
  76. data/spec/fixtures/documents/heading_paragraph.yaml +16 -0
  77. data/spec/fixtures/documents/lists_doc.yaml +32 -0
  78. data/spec/fixtures/documents/mixed_content.yaml +40 -0
  79. data/spec/fixtures/documents/nested_doc.yaml +20 -0
  80. data/spec/fixtures/documents/simple_doc.yaml +6 -0
  81. data/spec/fixtures/documents/table_doc.yaml +32 -0
  82. data/spec/fixtures/documents/transform_test.yaml +14 -0
  83. data/spec/fixtures/schema/custom_schema.rb +37 -0
  84. data/spec/fixtures/schema/test_schema.rb +46 -0
  85. data/spec/fixtures/test_builder/helpers.rb +212 -0
  86. data/spec/prosereflect/document_spec.rb +1 -1
  87. data/spec/prosereflect/fragment_spec.rb +273 -0
  88. data/spec/prosereflect/input/html_spec.rb +197 -1
  89. data/spec/prosereflect/node_spec.rb +128 -0
  90. data/spec/prosereflect/output/whitespace_spec.rb +248 -0
  91. data/spec/prosereflect/parser/round_trip_spec.rb +472 -0
  92. data/spec/prosereflect/resolved_pos_spec.rb +74 -0
  93. data/spec/prosereflect/schema/conftest.rb +68 -0
  94. data/spec/prosereflect/schema/content_match_spec.rb +237 -0
  95. data/spec/prosereflect/schema/mark_spec.rb +274 -0
  96. data/spec/prosereflect/schema/mark_type_spec.rb +86 -0
  97. data/spec/prosereflect/schema/node_type_spec.rb +142 -0
  98. data/spec/prosereflect/schema/schema_spec.rb +194 -0
  99. data/spec/prosereflect/test_builder/marks_spec.rb +127 -0
  100. data/spec/prosereflect/transform/equivalence_spec.rb +487 -0
  101. data/spec/prosereflect/transform/mapping_spec.rb +226 -0
  102. data/spec/prosereflect/transform/replace_spec.rb +832 -0
  103. data/spec/prosereflect/transform/replace_step_spec.rb +157 -0
  104. data/spec/prosereflect/transform/slice_spec.rb +48 -0
  105. data/spec/prosereflect/transform/step_map_spec.rb +70 -0
  106. data/spec/prosereflect/transform/step_spec.rb +211 -0
  107. data/spec/prosereflect/transform/structure_spec.rb +98 -0
  108. data/spec/prosereflect/transform/transform_spec.rb +238 -0
  109. data/spec/spec_helper.rb +1 -0
  110. metadata +90 -2
@@ -0,0 +1,487 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "yaml"
5
+
6
+ RSpec.describe "TransformEquivalence" do # rubocop:disable RSpec/DescribeClass
7
+ def parse_doc(yaml_string)
8
+ Prosereflect::Parser.parse_document(YAML.safe_load(yaml_string))
9
+ end
10
+
11
+ def parse_hash(hash)
12
+ Prosereflect::Parser.parse_document(hash)
13
+ end
14
+
15
+ describe "fixture round-trips" do
16
+ Dir[File.expand_path("../../fixtures/documents/*.yaml", __dir__)].each do |path|
17
+ it "round-trips #{File.basename(path)}" do
18
+ data = YAML.safe_load_file(path)
19
+ doc = Prosereflect::Parser.parse_document(data)
20
+ expect(doc.to_h).to eq(data)
21
+ end
22
+ end
23
+ end
24
+
25
+ describe "node type round-trips" do
26
+ it "round-trips paragraph with bold text" do
27
+ doc = parse_hash({
28
+ "type" => "doc",
29
+ "content" => [
30
+ {
31
+ "type" => "paragraph",
32
+ "content" => [
33
+ { "type" => "text", "text" => "bold", "marks" => [{ "type" => "bold" }] },
34
+ { "type" => "text", "text" => " and " },
35
+ { "type" => "text", "text" => "italic", "marks" => [{ "type" => "italic" }] },
36
+ ],
37
+ },
38
+ ],
39
+ })
40
+ expect(parse_hash(doc.to_h).to_h).to eq(doc.to_h)
41
+ end
42
+
43
+ it "round-trips heading with level" do
44
+ doc = parse_hash({
45
+ "type" => "doc",
46
+ "content" => [
47
+ { "type" => "heading", "attrs" => { "level" => 2 }, "content" => [{ "type" => "text", "text" => "Title" }] },
48
+ ],
49
+ })
50
+ expect(parse_hash(doc.to_h).to_h).to eq(doc.to_h)
51
+ end
52
+
53
+ it "round-trips link with href" do
54
+ doc = parse_hash({
55
+ "type" => "doc",
56
+ "content" => [
57
+ {
58
+ "type" => "paragraph",
59
+ "content" => [
60
+ { "type" => "text", "text" => "click here", "marks" => [{ "type" => "link", "attrs" => { "href" => "https://example.com" } }] },
61
+ ],
62
+ },
63
+ ],
64
+ })
65
+ expect(parse_hash(doc.to_h).to_h).to eq(doc.to_h)
66
+ end
67
+
68
+ it "round-trips nested blockquote" do
69
+ doc = parse_hash({
70
+ "type" => "doc",
71
+ "content" => [
72
+ {
73
+ "type" => "blockquote",
74
+ "content" => [
75
+ {
76
+ "type" => "paragraph",
77
+ "content" => [{ "type" => "text", "text" => "Nested quote" }],
78
+ },
79
+ ],
80
+ },
81
+ ],
82
+ })
83
+ expect(parse_hash(doc.to_h).to_h).to eq(doc.to_h)
84
+ end
85
+
86
+ it "round-trips table with headers and cells" do
87
+ doc = parse_hash({
88
+ "type" => "doc",
89
+ "content" => [
90
+ {
91
+ "type" => "table",
92
+ "content" => [
93
+ {
94
+ "type" => "table_row",
95
+ "content" => [
96
+ { "type" => "table_header", "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "H" }] }] },
97
+ ],
98
+ },
99
+ {
100
+ "type" => "table_row",
101
+ "content" => [
102
+ { "type" => "table_cell", "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "C" }] }] },
103
+ ],
104
+ },
105
+ ],
106
+ },
107
+ ],
108
+ })
109
+ expect(parse_hash(doc.to_h).to_h).to eq(doc.to_h)
110
+ end
111
+ end
112
+
113
+ describe "transform operations consistency" do
114
+ it "split and join are inverse for simple paragraphs" do
115
+ doc = parse_hash({
116
+ "type" => "doc",
117
+ "content" => [
118
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "Hello World" }] },
119
+ ],
120
+ })
121
+ # Split at position 5 creates two nodes
122
+ tx = Prosereflect::Transform::Transform.new(doc)
123
+ tx.split(5)
124
+ expect(tx.size).to eq(1)
125
+ end
126
+
127
+ it "delete and insert are consistent" do
128
+ doc = parse_hash({
129
+ "type" => "doc",
130
+ "content" => [
131
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "abc" }] },
132
+ ],
133
+ })
134
+ tx = Prosereflect::Transform::Transform.new(doc)
135
+ tx.delete(2, 4)
136
+ expect(tx.size).to eq(1)
137
+ end
138
+
139
+ it "replace step produces correct mapping" do
140
+ doc = parse_hash({
141
+ "type" => "doc",
142
+ "content" => [
143
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "hello" }] },
144
+ ],
145
+ })
146
+ tx = Prosereflect::Transform::Transform.new(doc)
147
+ tx.replace(2, 5, Prosereflect::Transform::Slice.empty)
148
+ expect(tx.size).to eq(1)
149
+ expect(tx.maps.length).to eq(1)
150
+ end
151
+
152
+ it "add_mark and remove_mark steps" do
153
+ doc = parse_hash({
154
+ "type" => "doc",
155
+ "content" => [
156
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "hello" }] },
157
+ ],
158
+ })
159
+ tx = Prosereflect::Transform::Transform.new(doc)
160
+ mark = Prosereflect::Mark::Bold.new
161
+ tx.add_mark(0, 5, mark)
162
+ tx.remove_mark(0, 5, mark)
163
+ expect(tx.size).to eq(2)
164
+ end
165
+ end
166
+
167
+ describe "node_size consistency" do
168
+ it "document with single paragraph" do
169
+ doc = parse_hash({
170
+ "type" => "doc",
171
+ "content" => [
172
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "hi" }] },
173
+ ],
174
+ })
175
+ # doc=1 + para=1 + text("hi")=3 = 5
176
+ expect(doc.node_size).to eq(5)
177
+ end
178
+
179
+ it "document with multiple paragraphs" do
180
+ doc = parse_hash({
181
+ "type" => "doc",
182
+ "content" => [
183
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "ab" }] },
184
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "cd" }] },
185
+ ],
186
+ })
187
+ # doc=1 + (para=1+text=3) + (para=1+text=3) = 9
188
+ expect(doc.node_size).to eq(9)
189
+ end
190
+
191
+ it "text node_size equals length + 1" do
192
+ text = Prosereflect::Text.new(text: "hello")
193
+ expect(text.node_size).to eq(6)
194
+ expect(text.node_size).to eq(text.text.length + 1)
195
+ end
196
+
197
+ it "empty text node_size is 1" do
198
+ text = Prosereflect::Text.new(text: "")
199
+ expect(text.node_size).to eq(1)
200
+ end
201
+ end
202
+
203
+ describe "resolve consistency" do
204
+ it "resolve(0) returns depth 0" do
205
+ doc = parse_hash({
206
+ "type" => "doc",
207
+ "content" => [
208
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "hi" }] },
209
+ ],
210
+ })
211
+ r = doc.resolve(0)
212
+ expect(r.depth).to eq(0)
213
+ expect(r.parent).to be_a(Prosereflect::Document)
214
+ end
215
+
216
+ it "resolve at paragraph boundary" do
217
+ doc = parse_hash({
218
+ "type" => "doc",
219
+ "content" => [
220
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "hi" }] },
221
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "there" }] },
222
+ ],
223
+ })
224
+ # Position 5 = after first paragraph (1+1+3=5), before second paragraph
225
+ r = doc.resolve(5)
226
+ expect(r.depth).to eq(1)
227
+ expect(r.parent).to be_a(Prosereflect::Paragraph)
228
+ end
229
+
230
+ it "resolve at start of document" do
231
+ doc = parse_hash({
232
+ "type" => "doc",
233
+ "content" => [
234
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "hi" }] },
235
+ ],
236
+ })
237
+ r = doc.resolve(0)
238
+ expect(r.depth).to eq(0)
239
+ expect(r.pos).to eq(0)
240
+ end
241
+
242
+ it "resolve at end of document" do
243
+ doc = parse_hash({
244
+ "type" => "doc",
245
+ "content" => [
246
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "hi" }] },
247
+ ],
248
+ })
249
+ # doc_size = 1+1+3 = 5
250
+ r = doc.resolve(5)
251
+ expect(r.depth).to eq(0)
252
+ expect(r.pos).to eq(5)
253
+ end
254
+
255
+ it "resolve at text position" do
256
+ doc = parse_hash({
257
+ "type" => "doc",
258
+ "content" => [
259
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "abc" }] },
260
+ ],
261
+ })
262
+ # pos 3 = inside text "abc" at character "c"
263
+ r = doc.resolve(3)
264
+ expect(r.depth).to eq(2)
265
+ expect(r.node(1)).to be_a(Prosereflect::Paragraph)
266
+ end
267
+
268
+ it "resolved position index is correct" do
269
+ doc = parse_hash({
270
+ "type" => "doc",
271
+ "content" => [
272
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "ab" }] },
273
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "cd" }] },
274
+ ],
275
+ })
276
+ # pos 0 = before doc content, index 0
277
+ r = doc.resolve(0)
278
+ expect(r.index(0)).to eq(0)
279
+ end
280
+
281
+ it "resolved position parent_offset is correct" do
282
+ doc = parse_hash({
283
+ "type" => "doc",
284
+ "content" => [
285
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "hi" }] },
286
+ ],
287
+ })
288
+ r = doc.resolve(3)
289
+ expect(r.parent_offset).to be >= 0
290
+ end
291
+ end
292
+
293
+ describe "schema equivalence" do
294
+ def basic_schema
295
+ Prosereflect::Schema.new(
296
+ nodes_spec: {
297
+ "doc" => { content: "block+" },
298
+ "paragraph" => { content: "inline*", group: "block" },
299
+ "heading" => { content: "inline*", group: "block", attrs: { "level" => { "default" => 1 } } },
300
+ "text" => { group: "inline" },
301
+ },
302
+ marks_spec: {
303
+ "bold" => {},
304
+ "italic" => {},
305
+ "link" => { attrs: { "href" => {} } },
306
+ },
307
+ )
308
+ end
309
+
310
+ it "schema has correct node types" do
311
+ schema = basic_schema
312
+ expect(schema.node_type("doc")).to be_a(Prosereflect::Schema::NodeType)
313
+ expect(schema.node_type("paragraph")).to be_a(Prosereflect::Schema::NodeType)
314
+ expect(schema.node_type("text")).to be_a(Prosereflect::Schema::NodeType)
315
+ end
316
+
317
+ it "schema has correct mark types" do
318
+ schema = basic_schema
319
+ expect(schema.mark_type("bold")).to be_a(Prosereflect::Schema::MarkType)
320
+ expect(schema.mark_type("italic")).to be_a(Prosereflect::Schema::MarkType)
321
+ end
322
+
323
+ it "schema raises for unknown node type" do
324
+ schema = basic_schema
325
+ expect { schema.node_type("unknown") }.to raise_error(Prosereflect::SchemaErrors::ValidationError)
326
+ end
327
+
328
+ it "schema raises for unknown mark type" do
329
+ schema = basic_schema
330
+ expect { schema.mark_type("unknown") }.to raise_error(Prosereflect::SchemaErrors::ValidationError)
331
+ end
332
+
333
+ it "schema top_node_type returns doc" do
334
+ schema = basic_schema
335
+ expect(schema.top_node_type.name).to eq("doc")
336
+ end
337
+
338
+ it "schema requires doc and text node types" do
339
+ expect do
340
+ Prosereflect::Schema.new(
341
+ nodes_spec: { "paragraph" => { content: "inline*" } },
342
+ marks_spec: {},
343
+ )
344
+ end.to raise_error(Prosereflect::SchemaErrors::ValidationError)
345
+ end
346
+
347
+ it "schema mark type create returns a mark" do
348
+ schema = basic_schema
349
+ mark = schema.mark_type("bold").create
350
+ expect(mark).to be_a(Prosereflect::Schema::Mark)
351
+ end
352
+
353
+ it "schema mark with attributes" do
354
+ schema = basic_schema
355
+ mark = schema.mark_type("link").create("href" => "https://example.com")
356
+ expect(mark.attrs).to include("href" => "https://example.com")
357
+ end
358
+ end
359
+
360
+ describe "HTML round-trip equivalence" do
361
+ it "round-trips simple HTML through parse" do
362
+ html = "<p>Hello world</p>"
363
+ doc = Prosereflect::Input::Html.parse(html)
364
+ para = doc.find_first("paragraph")
365
+ expect(para).to be_a(Prosereflect::Paragraph)
366
+ expect(para.text_content).to eq("Hello world")
367
+ end
368
+
369
+ it "round-trips formatted HTML through parse" do
370
+ html = "<p><strong>bold</strong> and <em>italic</em></p>"
371
+ doc = Prosereflect::Input::Html.parse(html)
372
+ para = doc.find_first("paragraph")
373
+ expect(para).to be_a(Prosereflect::Paragraph)
374
+ expect(para.text_content).to include("bold")
375
+ expect(para.text_content).to include("italic")
376
+ end
377
+
378
+ it "round-trips heading HTML" do
379
+ html = "<h2>Title</h2>"
380
+ doc = Prosereflect::Input::Html.parse(html)
381
+ expect(doc.find_first("heading")).to be_a(Prosereflect::Heading)
382
+ end
383
+
384
+ it "round-trips list HTML" do
385
+ html = "<ul><li>one</li><li>two</li></ul>"
386
+ doc = Prosereflect::Input::Html.parse(html)
387
+ expect(doc.find_first("bullet_list")).to be_a(Prosereflect::BulletList)
388
+ end
389
+ end
390
+
391
+ describe "node type round-trips with attrs" do
392
+ it "round-trips heading with all levels" do
393
+ (1..6).each do |level|
394
+ doc = parse_hash({
395
+ "type" => "doc",
396
+ "content" => [
397
+ { "type" => "heading", "attrs" => { "level" => level },
398
+ "content" => [{ "type" => "text", "text" => "H#{level}" }] },
399
+ ],
400
+ })
401
+ expect(parse_hash(doc.to_h).to_h).to eq(doc.to_h)
402
+ end
403
+ end
404
+
405
+ it "round-trips bullet list" do
406
+ doc = parse_hash({
407
+ "type" => "doc",
408
+ "content" => [
409
+ {
410
+ "type" => "bullet_list",
411
+ "content" => [
412
+ { "type" => "list_item", "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "item" }] }] },
413
+ ],
414
+ },
415
+ ],
416
+ })
417
+ expect(parse_hash(doc.to_h).to_h).to eq(doc.to_h)
418
+ end
419
+
420
+ it "round-trips ordered list" do
421
+ doc = parse_hash({
422
+ "type" => "doc",
423
+ "content" => [
424
+ {
425
+ "type" => "ordered_list",
426
+ "attrs" => { "start" => 1 },
427
+ "content" => [
428
+ { "type" => "list_item", "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "first" }] }] },
429
+ ],
430
+ },
431
+ ],
432
+ })
433
+ expect(parse_hash(doc.to_h).to_h).to eq(doc.to_h)
434
+ end
435
+
436
+ it "round-trips hard_break" do
437
+ doc = parse_hash({
438
+ "type" => "doc",
439
+ "content" => [
440
+ { "type" => "paragraph", "content" => [
441
+ { "type" => "text", "text" => "before" },
442
+ { "type" => "hard_break" },
443
+ { "type" => "text", "text" => "after" },
444
+ ] },
445
+ ],
446
+ })
447
+ expect(parse_hash(doc.to_h).to_h).to eq(doc.to_h)
448
+ end
449
+
450
+ it "round-trips horizontal_rule" do
451
+ doc = parse_hash({
452
+ "type" => "doc",
453
+ "content" => [
454
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "above" }] },
455
+ { "type" => "horizontal_rule" },
456
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "below" }] },
457
+ ],
458
+ })
459
+ expect(parse_hash(doc.to_h).to_h).to eq(doc.to_h)
460
+ end
461
+
462
+ it "round-trips code_block" do
463
+ doc = parse_hash({
464
+ "type" => "doc",
465
+ "content" => [
466
+ { "type" => "code_block_wrapper", "content" => [
467
+ { "type" => "code_block", "attrs" => { "language" => "ruby", "content" => "puts 'hi'" } },
468
+ ] },
469
+ ],
470
+ })
471
+ expect(parse_hash(doc.to_h).to_h).to eq(doc.to_h)
472
+ end
473
+
474
+ it "round-trips user mention" do
475
+ doc = parse_hash({
476
+ "type" => "doc",
477
+ "content" => [
478
+ { "type" => "paragraph", "content" => [
479
+ { "type" => "text", "text" => "hello " },
480
+ { "type" => "user", "attrs" => { "id" => "user-123" } },
481
+ ] },
482
+ ],
483
+ })
484
+ expect(parse_hash(doc.to_h).to_h).to eq(doc.to_h)
485
+ end
486
+ end
487
+ end