prosereflect 0.1.1 → 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 (158) 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/.github/workflows/rake.yml +4 -0
  5. data/.github/workflows/release.yml +5 -0
  6. data/.gitignore +4 -0
  7. data/.rubocop.yml +19 -1
  8. data/.rubocop_todo.yml +119 -183
  9. data/CLAUDE.md +78 -0
  10. data/Gemfile +8 -4
  11. data/README.adoc +2 -0
  12. data/Rakefile +3 -3
  13. data/docs/Gemfile +10 -0
  14. data/docs/INDEX.adoc +45 -0
  15. data/docs/_advanced/index.adoc +15 -0
  16. data/docs/_advanced/schema.adoc +112 -0
  17. data/docs/_advanced/step-map.adoc +66 -0
  18. data/docs/_advanced/steps.adoc +88 -0
  19. data/docs/_advanced/test-builder.adoc +61 -0
  20. data/docs/_advanced/transform.adoc +92 -0
  21. data/docs/_config.yml +174 -0
  22. data/docs/_features/html-input.adoc +69 -0
  23. data/docs/_features/html-output.adoc +45 -0
  24. data/docs/_features/index.adoc +15 -0
  25. data/docs/_features/marks.adoc +86 -0
  26. data/docs/_features/node-types.adoc +124 -0
  27. data/docs/_features/user-mentions.adoc +47 -0
  28. data/docs/_guides/custom-nodes.adoc +107 -0
  29. data/docs/_guides/index.adoc +13 -0
  30. data/docs/_guides/round-trip-html.adoc +91 -0
  31. data/docs/_guides/serialization.adoc +109 -0
  32. data/docs/_pages/index.adoc +67 -0
  33. data/docs/_reference/document-api.adoc +49 -0
  34. data/docs/_reference/index.adoc +14 -0
  35. data/docs/_reference/node-api.adoc +79 -0
  36. data/docs/_reference/schema-api.adoc +95 -0
  37. data/docs/_reference/transform-api.adoc +77 -0
  38. data/docs/_understanding/document-model.adoc +65 -0
  39. data/docs/_understanding/fragment.adoc +52 -0
  40. data/docs/_understanding/index.adoc +14 -0
  41. data/docs/_understanding/resolved-position.adoc +53 -0
  42. data/docs/_understanding/slice.adoc +54 -0
  43. data/docs/lychee.toml +63 -0
  44. data/lib/prosereflect/attribute/base.rb +4 -6
  45. data/lib/prosereflect/attribute/bold.rb +2 -4
  46. data/lib/prosereflect/attribute/href.rb +1 -3
  47. data/lib/prosereflect/attribute/id.rb +7 -7
  48. data/lib/prosereflect/attribute.rb +4 -7
  49. data/lib/prosereflect/blockquote.rb +19 -11
  50. data/lib/prosereflect/bullet_list.rb +36 -29
  51. data/lib/prosereflect/code_block.rb +23 -27
  52. data/lib/prosereflect/code_block_wrapper.rb +12 -13
  53. data/lib/prosereflect/document.rb +14 -22
  54. data/lib/prosereflect/fragment.rb +249 -0
  55. data/lib/prosereflect/hard_break.rb +6 -6
  56. data/lib/prosereflect/heading.rb +14 -15
  57. data/lib/prosereflect/horizontal_rule.rb +23 -14
  58. data/lib/prosereflect/image.rb +32 -23
  59. data/lib/prosereflect/input/html.rb +179 -104
  60. data/lib/prosereflect/input.rb +7 -0
  61. data/lib/prosereflect/list_item.rb +11 -12
  62. data/lib/prosereflect/mark/base.rb +9 -11
  63. data/lib/prosereflect/mark/bold.rb +1 -3
  64. data/lib/prosereflect/mark/code.rb +1 -3
  65. data/lib/prosereflect/mark/italic.rb +1 -3
  66. data/lib/prosereflect/mark/link.rb +1 -3
  67. data/lib/prosereflect/mark/strike.rb +1 -3
  68. data/lib/prosereflect/mark/subscript.rb +1 -3
  69. data/lib/prosereflect/mark/superscript.rb +1 -3
  70. data/lib/prosereflect/mark/underline.rb +1 -3
  71. data/lib/prosereflect/mark.rb +9 -5
  72. data/lib/prosereflect/node.rb +171 -33
  73. data/lib/prosereflect/ordered_list.rb +17 -14
  74. data/lib/prosereflect/output/html.rb +279 -50
  75. data/lib/prosereflect/output.rb +7 -0
  76. data/lib/prosereflect/paragraph.rb +11 -13
  77. data/lib/prosereflect/parser.rb +56 -66
  78. data/lib/prosereflect/resolved_pos.rb +256 -0
  79. data/lib/prosereflect/schema/attribute.rb +57 -0
  80. data/lib/prosereflect/schema/content_match.rb +656 -0
  81. data/lib/prosereflect/schema/fragment.rb +166 -0
  82. data/lib/prosereflect/schema/mark.rb +121 -0
  83. data/lib/prosereflect/schema/mark_type.rb +130 -0
  84. data/lib/prosereflect/schema/node.rb +236 -0
  85. data/lib/prosereflect/schema/node_type.rb +274 -0
  86. data/lib/prosereflect/schema/schema_main.rb +190 -0
  87. data/lib/prosereflect/schema/spec.rb +92 -0
  88. data/lib/prosereflect/schema.rb +39 -0
  89. data/lib/prosereflect/table.rb +12 -13
  90. data/lib/prosereflect/table_cell.rb +13 -13
  91. data/lib/prosereflect/table_header.rb +17 -17
  92. data/lib/prosereflect/table_row.rb +12 -12
  93. data/lib/prosereflect/text.rb +35 -11
  94. data/lib/prosereflect/transform/attr_step.rb +157 -0
  95. data/lib/prosereflect/transform/insert_step.rb +115 -0
  96. data/lib/prosereflect/transform/mapping.rb +82 -0
  97. data/lib/prosereflect/transform/mark_step.rb +269 -0
  98. data/lib/prosereflect/transform/replace_around_step.rb +181 -0
  99. data/lib/prosereflect/transform/replace_step.rb +157 -0
  100. data/lib/prosereflect/transform/slice.rb +91 -0
  101. data/lib/prosereflect/transform/step.rb +89 -0
  102. data/lib/prosereflect/transform/step_map.rb +126 -0
  103. data/lib/prosereflect/transform/structure.rb +120 -0
  104. data/lib/prosereflect/transform/transform.rb +341 -0
  105. data/lib/prosereflect/transform.rb +26 -0
  106. data/lib/prosereflect/user.rb +15 -15
  107. data/lib/prosereflect/version.rb +1 -1
  108. data/lib/prosereflect.rb +30 -17
  109. data/prosereflect.gemspec +17 -16
  110. data/spec/fixtures/documents/formatted_text.yaml +14 -0
  111. data/spec/fixtures/documents/heading_paragraph.yaml +16 -0
  112. data/spec/fixtures/documents/lists_doc.yaml +32 -0
  113. data/spec/fixtures/documents/mixed_content.yaml +40 -0
  114. data/spec/fixtures/documents/nested_doc.yaml +20 -0
  115. data/spec/fixtures/documents/simple_doc.yaml +6 -0
  116. data/spec/fixtures/documents/table_doc.yaml +32 -0
  117. data/spec/fixtures/documents/transform_test.yaml +14 -0
  118. data/spec/fixtures/schema/custom_schema.rb +37 -0
  119. data/spec/fixtures/schema/test_schema.rb +46 -0
  120. data/spec/fixtures/test_builder/helpers.rb +212 -0
  121. data/spec/prosereflect/document_spec.rb +332 -330
  122. data/spec/prosereflect/fragment_spec.rb +273 -0
  123. data/spec/prosereflect/hard_break_spec.rb +125 -125
  124. data/spec/prosereflect/input/html_spec.rb +718 -522
  125. data/spec/prosereflect/node_spec.rb +311 -182
  126. data/spec/prosereflect/output/html_spec.rb +105 -105
  127. data/spec/prosereflect/output/whitespace_spec.rb +248 -0
  128. data/spec/prosereflect/paragraph_spec.rb +275 -274
  129. data/spec/prosereflect/parser/round_trip_spec.rb +472 -0
  130. data/spec/prosereflect/parser_spec.rb +185 -180
  131. data/spec/prosereflect/resolved_pos_spec.rb +74 -0
  132. data/spec/prosereflect/schema/conftest.rb +68 -0
  133. data/spec/prosereflect/schema/content_match_spec.rb +237 -0
  134. data/spec/prosereflect/schema/mark_spec.rb +274 -0
  135. data/spec/prosereflect/schema/mark_type_spec.rb +86 -0
  136. data/spec/prosereflect/schema/node_type_spec.rb +142 -0
  137. data/spec/prosereflect/schema/schema_spec.rb +194 -0
  138. data/spec/prosereflect/table_cell_spec.rb +183 -183
  139. data/spec/prosereflect/table_row_spec.rb +149 -149
  140. data/spec/prosereflect/table_spec.rb +320 -318
  141. data/spec/prosereflect/test_builder/marks_spec.rb +127 -0
  142. data/spec/prosereflect/text_spec.rb +133 -132
  143. data/spec/prosereflect/transform/equivalence_spec.rb +487 -0
  144. data/spec/prosereflect/transform/mapping_spec.rb +226 -0
  145. data/spec/prosereflect/transform/replace_spec.rb +832 -0
  146. data/spec/prosereflect/transform/replace_step_spec.rb +157 -0
  147. data/spec/prosereflect/transform/slice_spec.rb +48 -0
  148. data/spec/prosereflect/transform/step_map_spec.rb +70 -0
  149. data/spec/prosereflect/transform/step_spec.rb +211 -0
  150. data/spec/prosereflect/transform/structure_spec.rb +98 -0
  151. data/spec/prosereflect/transform/transform_spec.rb +238 -0
  152. data/spec/prosereflect/user_spec.rb +31 -28
  153. data/spec/prosereflect_spec.rb +28 -26
  154. data/spec/spec_helper.rb +7 -6
  155. data/spec/support/matchers.rb +6 -6
  156. data/spec/support/shared_examples.rb +49 -49
  157. metadata +96 -5
  158. data/spec/prosereflect/version_spec.rb +0 -11
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "prosereflect"
5
+
6
+ RSpec.configure do |config|
7
+ config.example_status_persistence_file_path = ".rspec_status"
8
+ config.disable_monkey_patching!
9
+ config.expect_with :rspec do |c|
10
+ c.syntax = :expect
11
+ end
12
+ end
13
+
14
+ # Standard test schema matching prosemirror-py test_builder
15
+ RSpec.shared_context "test_schema" do
16
+ let(:test_schema) do
17
+ Prosereflect::Schema.new(
18
+ nodes_spec: {
19
+ "doc" => { content: "block+" },
20
+ "paragraph" => { content: "inline*", group: "block" },
21
+ "heading" => { content: "inline*",
22
+ attrs: { "level" => { default: 1 } }, group: "block" },
23
+ "blockquote" => { content: "block+", group: "block" },
24
+ "code_block" => { content: "text*", marks: "", code: true,
25
+ group: "block" },
26
+ "horizontal_rule" => { group: "block" },
27
+ "text" => { group: "inline" },
28
+ "image" => { inline: true, group: "inline", atom: true,
29
+ attrs: { "src" => {}, "alt" => { default: "" }, "title" => { default: "" } } },
30
+ "hard_break" => { inline: true, group: "inline", atom: true },
31
+ "ordered_list" => { content: "list_item+", group: "block",
32
+ attrs: { "order" => { default: 1 } } },
33
+ "bullet_list" => { content: "list_item+", group: "block" },
34
+ "list_item" => { content: "paragraph block*", defining: true },
35
+ },
36
+ marks_spec: {
37
+ "link" => { attrs: { "href" => { default: "" }, "title" => { default: "" } },
38
+ inclusive: false },
39
+ "em" => { group: "mark" },
40
+ "strong" => { group: "mark" },
41
+ "code" => { group: "mark" },
42
+ },
43
+ )
44
+ end
45
+
46
+ let(:schema) { test_schema }
47
+ end
48
+
49
+ RSpec.shared_context "custom_schema" do
50
+ let(:custom_schema) do
51
+ Prosereflect::Schema.new(
52
+ nodes_spec: {
53
+ "doc" => { content: "block+" },
54
+ "paragraph" => { content: "inline*", group: "block" },
55
+ "text" => { group: "inline" },
56
+ },
57
+ marks_spec: {
58
+ "bold" => {},
59
+ "italic" => {},
60
+ "link" => { attrs: { "href" => {} }, inclusive: false,
61
+ excludes: "emoji" },
62
+ "emoji" => { group: "mark" },
63
+ },
64
+ )
65
+ end
66
+
67
+ let(:schema) { custom_schema }
68
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "conftest"
5
+
6
+ RSpec.describe Prosereflect::Schema::ContentMatch do
7
+ include_context "test_schema"
8
+
9
+ def get(expression)
10
+ described_class.parse(expression, schema.nodes)
11
+ end
12
+
13
+ def match(expression, types)
14
+ m = get(expression)
15
+ ts = types.split.map { |t| schema.nodes[t] }
16
+ i = 0
17
+ while m && i < ts.length
18
+ m = m.match_type(ts[i])
19
+ i += 1
20
+ end
21
+ m ? m.valid_end : false
22
+ end
23
+
24
+ describe "parsing content expressions" do
25
+ it "parses empty expression" do
26
+ expect(get("").valid_end).to be true
27
+ end
28
+
29
+ it "parses 'image*' with no content" do
30
+ expect(match("image*", "")).to be true
31
+ end
32
+
33
+ it "parses 'image*' with image" do
34
+ expect(match("image*", "image")).to be true
35
+ end
36
+
37
+ it "parses 'image*' with multiple images" do
38
+ expect(match("image*", "image image image image")).to be true
39
+ end
40
+
41
+ it "rejects 'image*' with image and text" do
42
+ expect(match("image*", "image text")).to be false
43
+ end
44
+
45
+ it "parses 'inline*' with inline content" do
46
+ expect(match("inline*", "image text")).to be true
47
+ end
48
+
49
+ it "rejects 'inline*' with paragraph" do
50
+ expect(match("inline*", "paragraph")).to be false
51
+ end
52
+ end
53
+
54
+ describe "choice expressions" do
55
+ it "parses '(paragraph | heading)' with paragraph" do
56
+ expect(match("(paragraph | heading)", "paragraph")).to be true
57
+ end
58
+
59
+ it "parses '(paragraph | heading)' with heading" do
60
+ expect(match("(paragraph | heading)", "heading")).to be true
61
+ end
62
+
63
+ it "rejects '(paragraph | heading)' with image" do
64
+ expect(match("(paragraph | heading)", "image")).to be false
65
+ end
66
+ end
67
+
68
+ describe "sequence expressions" do
69
+ it "parses 'paragraph horizontal_rule paragraph'" do
70
+ expect(match("paragraph horizontal_rule paragraph",
71
+ "paragraph horizontal_rule paragraph")).to be true
72
+ end
73
+
74
+ it "rejects 'paragraph horizontal_rule' when given extra paragraph" do
75
+ expect(match("paragraph horizontal_rule",
76
+ "paragraph horizontal_rule paragraph")).to be false
77
+ end
78
+
79
+ it "rejects 'paragraph horizontal_rule paragraph' when missing final paragraph" do
80
+ expect(match("paragraph horizontal_rule paragraph",
81
+ "paragraph horizontal_rule")).to be false
82
+ end
83
+
84
+ it "rejects when order doesn't match" do
85
+ expect(match("paragraph horizontal_rule",
86
+ "horizontal_rule paragraph horizontal_rule")).to be false
87
+ end
88
+ end
89
+
90
+ describe "optional expressions (*)" do
91
+ it "parses 'heading paragraph*' with just heading" do
92
+ expect(match("heading paragraph*", "heading")).to be true
93
+ end
94
+
95
+ it "parses 'heading paragraph*' with multiple paragraphs" do
96
+ expect(match("heading paragraph*",
97
+ "heading paragraph paragraph")).to be true
98
+ end
99
+
100
+ it "parses 'paragraph paragraph*' with paragraph" do
101
+ expect(match("paragraph paragraph*", "paragraph")).to be true
102
+ end
103
+ end
104
+
105
+ describe "required expressions (+)" do
106
+ it "parses 'heading paragraph+' with heading and paragraph" do
107
+ expect(match("heading paragraph+", "heading paragraph")).to be true
108
+ end
109
+
110
+ it "parses 'heading paragraph+' with multiple paragraphs" do
111
+ expect(match("heading paragraph+",
112
+ "heading paragraph paragraph")).to be true
113
+ end
114
+
115
+ it "rejects 'heading paragraph+' with only heading" do
116
+ expect(match("heading paragraph+", "heading")).to be false
117
+ end
118
+
119
+ it "rejects 'paragraph paragraph+' when first is not paragraph" do
120
+ expect(match("paragraph paragraph+",
121
+ "horizontal_rule paragraph")).to be false
122
+ end
123
+ end
124
+
125
+ describe "optional single (?)" do
126
+ it "parses 'image?' with image" do
127
+ expect(match("image?", "image")).to be true
128
+ end
129
+
130
+ it "parses 'image?' with empty" do
131
+ expect(match("image?", "")).to be true
132
+ end
133
+
134
+ it "rejects 'image?' with multiple images" do
135
+ expect(match("image?", "image image")).to be false
136
+ end
137
+ end
138
+
139
+ describe "repeated choice with +" do
140
+ it "parses '(heading paragraph+)+' correctly" do
141
+ expect(match("(heading paragraph+)+",
142
+ "heading paragraph heading paragraph paragraph")).to be true
143
+ end
144
+
145
+ it "rejects when extra content at end" do
146
+ expect(match("(heading paragraph+)+",
147
+ "heading paragraph heading paragraph paragraph horizontal_rule")).to be false
148
+ end
149
+ end
150
+
151
+ describe "range expressions" do
152
+ it "parses 'hard_break{2}' with two breaks" do
153
+ expect(match("hard_break{2}", "hard_break hard_break")).to be true
154
+ end
155
+
156
+ it "rejects 'hard_break{2}' with only one" do
157
+ expect(match("hard_break{2}", "hard_break")).to be false
158
+ end
159
+
160
+ it "rejects 'hard_break{2}' with three" do
161
+ expect(match("hard_break{2}",
162
+ "hard_break hard_break hard_break")).to be false
163
+ end
164
+
165
+ it "parses 'hard_break{2,4}' with 2-4 breaks" do
166
+ expect(match("hard_break{2,4}", "hard_break hard_break")).to be true
167
+ expect(match("hard_break{2,4}",
168
+ "hard_break hard_break hard_break")).to be true
169
+ expect(match("hard_break{2,4}",
170
+ "hard_break hard_break hard_break hard_break")).to be true
171
+ end
172
+
173
+ it "rejects 'hard_break{2,4}' with too few" do
174
+ expect(match("hard_break{2,4}", "hard_break")).to be false
175
+ end
176
+
177
+ it "rejects 'hard_break{2,4}' with too many" do
178
+ expect(match("hard_break{2,4}",
179
+ "hard_break hard_break hard_break hard_break hard_break")).to be false
180
+ end
181
+
182
+ it "parses 'hard_break{2,}' (unbounded)" do
183
+ expect(match("hard_break{2,}", "hard_break hard_break")).to be true
184
+ expect(match("hard_break{2,}",
185
+ "hard_break hard_break hard_break hard_break")).to be true
186
+ end
187
+
188
+ it "rejects 'hard_break{2,}' with only one" do
189
+ expect(match("hard_break{2,}", "hard_break")).to be false
190
+ end
191
+ end
192
+
193
+ describe "mixed expressions" do
194
+ it "parses 'hard_break{2,4} text*'" do
195
+ expect(match("hard_break{2,4} text*", "hard_break hard_break")).to be true
196
+ expect(match("hard_break{2,4} text*",
197
+ "hard_break hard_break text text")).to be true
198
+ end
199
+
200
+ it "rejects 'hard_break{2,4} text*' with invalid content" do
201
+ expect(match("hard_break{2,4} text*",
202
+ "hard_break hard_break image")).to be false
203
+ end
204
+
205
+ it "parses 'hard_break{2,4} image?'" do
206
+ expect(match("hard_break{2,4} image?",
207
+ "hard_break hard_break image")).to be true
208
+ expect(match("hard_break{2,4} image?",
209
+ "hard_break hard_break")).to be true
210
+ end
211
+ end
212
+
213
+ describe "edge_count" do
214
+ it "returns number of edges" do
215
+ m = get("paragraph | heading")
216
+ expect(m.edge_count).to eq(2)
217
+ end
218
+
219
+ it "returns 0 for empty match" do
220
+ m = get("")
221
+ expect(m.edge_count).to eq(0)
222
+ end
223
+ end
224
+
225
+ describe "edge" do
226
+ it "returns edge at index" do
227
+ m = get("paragraph | heading")
228
+ expect(m.edge(0).type.name).to eq("paragraph")
229
+ expect(m.edge(1).type.name).to eq("heading")
230
+ end
231
+
232
+ it "raises error for out of bounds index" do
233
+ m = get("paragraph")
234
+ expect { m.edge(1) }.to raise_error(Prosereflect::Schema::ContentMatchError)
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "conftest"
5
+
6
+ RSpec.describe Prosereflect::Schema::Mark do
7
+ include_context "test_schema"
8
+
9
+ # Use test_schema by default
10
+ let(:schema) { test_schema }
11
+
12
+ describe ".same_set" do
13
+ it "returns true for identical mark sets" do
14
+ em1 = schema.mark_type("em").create
15
+ em2 = schema.mark_type("em").create
16
+ strong = schema.mark_type("strong").create
17
+
18
+ expect(described_class.same_set([em1, strong], [em2, strong])).to be true
19
+ end
20
+
21
+ it "returns false for different mark sets" do
22
+ em = schema.mark_type("em").create
23
+ strong = schema.mark_type("strong").create
24
+ code = schema.mark_type("code").create
25
+
26
+ expect(described_class.same_set([em, strong], [em, code])).to be false
27
+ end
28
+
29
+ it "returns false for sets of different lengths" do
30
+ em = schema.mark_type("em").create
31
+ strong = schema.mark_type("strong").create
32
+
33
+ expect(described_class.same_set([em, strong], [em])).to be false
34
+ end
35
+
36
+ it "returns true for empty sets" do
37
+ expect(described_class.same_set([], [])).to be true
38
+ end
39
+
40
+ it "compares marks with same type but different attrs" do
41
+ link1 = schema.mark_type("link").create({ "href" => "http://foo" })
42
+ link2 = schema.mark_type("link").create({ "href" => "http://bar" })
43
+
44
+ expect(described_class.same_set([link1], [link2])).to be false
45
+ end
46
+
47
+ it "compares marks with same type and same attrs" do
48
+ link1 = schema.mark_type("link").create({ "href" => "http://foo" })
49
+ link2 = schema.mark_type("link").create({ "href" => "http://foo" })
50
+
51
+ expect(described_class.same_set([link1], [link2])).to be true
52
+ end
53
+ end
54
+
55
+ describe "#eq?" do
56
+ it "returns true for marks with same type and attrs" do
57
+ link1 = schema.mark_type("link").create({ "href" => "http://foo" })
58
+ link2 = schema.mark_type("link").create({ "href" => "http://foo" })
59
+
60
+ expect(link1.eq?(link2)).to be true
61
+ end
62
+
63
+ it "returns false for marks with different attrs" do
64
+ link1 = schema.mark_type("link").create({ "href" => "http://foo" })
65
+ link2 = schema.mark_type("link").create({ "href" => "http://bar" })
66
+
67
+ expect(link1.eq?(link2)).to be false
68
+ end
69
+
70
+ it "returns false for marks with different types" do
71
+ link = schema.mark_type("link").create({ "href" => "http://foo" })
72
+ em = schema.mark_type("em").create
73
+
74
+ expect(link.eq?(em)).to be false
75
+ end
76
+
77
+ it "returns true for same object" do
78
+ link = schema.mark_type("link").create({ "href" => "http://foo" })
79
+
80
+ expect(link.eq?(link)).to be true
81
+ end
82
+ end
83
+
84
+ describe "#add_to_set" do
85
+ it "adds mark to empty set" do
86
+ em = schema.mark_type("em").create
87
+
88
+ result = em.add_to_set([])
89
+
90
+ expect(result.length).to eq(1)
91
+ expect(result.first.type.name).to eq("em")
92
+ end
93
+
94
+ it "returns original set if mark already exists" do
95
+ em = schema.mark_type("em").create
96
+ existing_set = [em]
97
+
98
+ result = em.add_to_set(existing_set)
99
+
100
+ expect(result).to equal(existing_set)
101
+ end
102
+
103
+ it "adds mark to set with other marks of different types" do
104
+ em = schema.mark_type("em").create
105
+ strong = schema.mark_type("strong").create
106
+
107
+ result = em.add_to_set([strong])
108
+
109
+ expect(result.length).to eq(2)
110
+ expect(result.map { |x| x.type.name }).to include("em", "strong")
111
+ end
112
+
113
+ it "adds link mark with different href replacing existing link" do
114
+ link1 = schema.mark_type("link").create({ "href" => "http://foo" })
115
+ link2 = schema.mark_type("link").create({ "href" => "http://bar" })
116
+ em = schema.mark_type("em").create
117
+
118
+ result = link2.add_to_set([link1, em])
119
+
120
+ # link2 should replace link1, em should remain
121
+ expect(result.length).to eq(2)
122
+ link_result = result.find { |m| m.type.name == "link" }
123
+ expect(link_result.attrs["href"]).to eq("http://bar")
124
+ end
125
+
126
+ it "maintains rank ordering" do
127
+ em = schema.mark_type("em").create
128
+ strong = schema.mark_type("strong").create
129
+ code = schema.mark_type("code").create
130
+
131
+ # Add marks in different order and verify sorting
132
+ result = code.add_to_set([em, strong])
133
+
134
+ # Should be sorted by rank (em=0, strong=1, code=2)
135
+ expect(result.map { |x| x.type.name }).to eq(["em", "strong", "code"])
136
+ end
137
+ end
138
+
139
+ describe "#remove_from_set" do
140
+ it "removes mark from set" do
141
+ em = schema.mark_type("em").create
142
+ strong = schema.mark_type("strong").create
143
+
144
+ result = em.remove_from_set([em, strong])
145
+
146
+ expect(result.length).to eq(1)
147
+ expect(result.first.type.name).to eq("strong")
148
+ end
149
+
150
+ it "returns unchanged set if mark not present" do
151
+ em = schema.mark_type("em").create
152
+ strong = schema.mark_type("strong").create
153
+ code = schema.mark_type("code").create
154
+
155
+ result = em.remove_from_set([strong, code])
156
+
157
+ expect(result.length).to eq(2)
158
+ end
159
+
160
+ it "returns empty set when removing only mark" do
161
+ em = schema.mark_type("em").create
162
+
163
+ result = em.remove_from_set([em])
164
+
165
+ expect(result).to be_empty
166
+ end
167
+ end
168
+
169
+ describe "#is_in_set?" do
170
+ it "returns true when mark is in set" do
171
+ em = schema.mark_type("em").create
172
+ strong = schema.mark_type("strong").create
173
+
174
+ expect(em.is_in_set?([em, strong])).to be true
175
+ end
176
+
177
+ it "returns false when mark is not in set" do
178
+ em = schema.mark_type("em").create
179
+ strong = schema.mark_type("strong").create
180
+
181
+ expect(em.is_in_set?([strong])).to be false
182
+ end
183
+ end
184
+
185
+ context "with custom schema exclusion rules" do
186
+ # Custom schema with link excludes emoji
187
+ let(:custom_schema) do
188
+ Prosereflect::Schema.new(
189
+ nodes_spec: {
190
+ "doc" => { content: "block+" },
191
+ "paragraph" => { content: "inline*", group: "block" },
192
+ "text" => { group: "inline" },
193
+ },
194
+ marks_spec: {
195
+ "bold" => {},
196
+ "italic" => {},
197
+ "link" => { attrs: { "href" => {} }, inclusive: false,
198
+ excludes: "emoji" },
199
+ "emoji" => { group: "mark" },
200
+ },
201
+ )
202
+ end
203
+
204
+ let(:schema) { custom_schema }
205
+
206
+ describe ".same_set" do
207
+ it "returns true for identical mark sets" do
208
+ bold1 = schema.mark_type("bold").create
209
+ bold2 = schema.mark_type("bold").create
210
+ italic = schema.mark_type("italic").create
211
+
212
+ expect(described_class.same_set([bold1, italic], [bold2, italic])).to be true
213
+ end
214
+ end
215
+
216
+ describe "#add_to_set" do
217
+ it "link excludes emoji - emoji removed when link added" do
218
+ # link excludes emoji in this schema
219
+ link = schema.mark_type("link").create({ "href" => "http://example" })
220
+ emoji = schema.mark_type("emoji").create
221
+
222
+ result = link.add_to_set([emoji])
223
+
224
+ # emoji should be removed because link excludes emoji
225
+ expect(result.map { |x| x.type.name }).not_to include("emoji")
226
+ expect(result.map { |x| x.type.name }).to include("link")
227
+ end
228
+
229
+ it "link can be added to empty set" do
230
+ link = schema.mark_type("link").create({ "href" => "http://example" })
231
+
232
+ result = link.add_to_set([])
233
+
234
+ expect(result.length).to eq(1)
235
+ expect(result.first.type.name).to eq("link")
236
+ end
237
+
238
+ it "emoji does not exclude link but link excludes emoji" do
239
+ # emoji has no excludes, but link excludes emoji
240
+ # So when we try to add emoji to [link], it should fail
241
+ # because link excludes emoji
242
+ link = schema.mark_type("link").create({ "href" => "http://example" })
243
+ emoji = schema.mark_type("emoji").create
244
+
245
+ result = emoji.add_to_set([link])
246
+
247
+ # link already in set, and link excludes emoji, so emoji not added
248
+ expect(result.map { |x| x.type.name }).to eq(["link"])
249
+ end
250
+ end
251
+
252
+ describe "bold and italic coexist" do
253
+ it "bold and italic can coexist in a set" do
254
+ bold = schema.mark_type("bold").create
255
+ italic = schema.mark_type("italic").create
256
+
257
+ result = bold.add_to_set([italic])
258
+
259
+ expect(result.length).to eq(2)
260
+ expect(result.map { |x| x.type.name }).to include("bold", "italic")
261
+ end
262
+
263
+ it "italic and bold can coexist in a set" do
264
+ bold = schema.mark_type("bold").create
265
+ italic = schema.mark_type("italic").create
266
+
267
+ result = italic.add_to_set([bold])
268
+
269
+ expect(result.length).to eq(2)
270
+ expect(result.map { |x| x.type.name }).to include("bold", "italic")
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "conftest"
5
+
6
+ RSpec.describe Prosereflect::Schema::MarkType do
7
+ include_context "test_schema"
8
+
9
+ describe "create" do
10
+ it "creates a mark with attrs" do
11
+ mark = schema.mark_type("link").create({ "href" => "http://example.com" })
12
+ expect(mark.attrs["href"]).to eq("http://example.com")
13
+ end
14
+
15
+ it "uses default instance when no attrs provided" do
16
+ mark_type = schema.mark_type("link")
17
+ mark = mark_type.create(nil)
18
+ # The instance should have the default title
19
+ expect(mark.type).to eq(mark_type)
20
+ end
21
+ end
22
+
23
+ describe "remove_from_set" do
24
+ it "removes mark from set" do
25
+ mark_type = schema.mark_type("em")
26
+ mark = mark_type.create
27
+ mark_set = [mark]
28
+
29
+ result = mark_type.remove_from_set(mark_set)
30
+ expect(result).to be_empty
31
+ end
32
+
33
+ it "keeps other marks" do
34
+ em_mark = schema.mark_type("em").create
35
+ strong_mark = schema.mark_type("strong").create
36
+ mark_set = [em_mark, strong_mark]
37
+
38
+ result = schema.mark_type("em").remove_from_set(mark_set)
39
+ expect(result).to eq([strong_mark])
40
+ end
41
+ end
42
+
43
+ describe "is_in_set?" do
44
+ it "returns true when mark is in set" do
45
+ mark = schema.mark_type("em").create
46
+ mark_set = [mark]
47
+
48
+ expect(schema.mark_type("em").is_in_set?(mark_set)).to be true
49
+ end
50
+
51
+ it "returns false when mark is not in set" do
52
+ mark = schema.mark_type("em").create
53
+ mark_set = [mark]
54
+
55
+ expect(schema.mark_type("strong").is_in_set?(mark_set)).to be false
56
+ end
57
+ end
58
+
59
+ describe "excludes?" do
60
+ it "returns false by default" do
61
+ expect(schema.mark_type("em").excludes?(schema.mark_type("strong"))).to be false
62
+ end
63
+ end
64
+
65
+ describe "with custom_schema (exclusions)" do
66
+ include_context "custom_schema"
67
+
68
+ it "link excludes emoji" do
69
+ expect(custom_schema.mark_type("link").excludes?(custom_schema.mark_type("emoji"))).to be true
70
+ end
71
+
72
+ it "emoji does not exclude link" do
73
+ expect(custom_schema.mark_type("emoji").excludes?(custom_schema.mark_type("link"))).to be false
74
+ end
75
+ end
76
+
77
+ describe "inclusive?" do
78
+ it "returns false for link (non-inclusive)" do
79
+ expect(schema.mark_type("link").inclusive?).to be false
80
+ end
81
+
82
+ it "returns true for em (inclusive by default)" do
83
+ expect(schema.mark_type("em").inclusive?).to be true
84
+ end
85
+ end
86
+ end