commenter 0.2.3 → 0.3.1

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.
@@ -169,6 +169,31 @@ RSpec.describe Commenter::CommentSheet do
169
169
  expect(sheet.comments.length).to eq(1)
170
170
  end
171
171
  end
172
+ end
173
+
174
+ RSpec.describe Commenter::CommentSheet do
175
+ describe "#schema_name" do
176
+ it "selects the schema for each sheet version" do
177
+ expect(described_class.new(version: "osd").schema_name).to eq("iso_comment_osd.yaml")
178
+ expect(described_class.new(version: "2012-03").schema_name).to eq("iso_comment_2012-03.yaml")
179
+ expect(described_class.new({}).schema_name).to eq("iso_comment_2012-03.yaml")
180
+ end
181
+ end
182
+
183
+ describe "#to_yaml_document" do
184
+ it "prepends a header referencing the schema matching the version" do
185
+ document = described_class.new(version: "osd", comments: []).to_yaml_document
186
+
187
+ expect(document.lines.first).to eq("# yaml-language-server: $schema=schema/iso_comment_osd.yaml\n")
188
+ expect(YAML.safe_load(document)).to include("version" => "osd")
189
+ end
190
+
191
+ it "accepts a schema directory" do
192
+ document = described_class.new(comments: []).to_yaml_document("schemas")
193
+
194
+ expect(document.lines.first).to eq("# yaml-language-server: $schema=schemas/iso_comment_2012-03.yaml\n")
195
+ end
196
+ end
172
197
 
173
198
  describe "stage validation" do
174
199
  it "accepts valid stage values" do
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Commenter::CommentType do
6
+ describe ".code" do
7
+ it "maps full names and codes to codes" do
8
+ expect(described_class.code("Editorial")).to eq("ed")
9
+ expect(described_class.code("general")).to eq("ge")
10
+ expect(described_class.code("te")).to eq("te")
11
+ end
12
+
13
+ it "passes unrecognized values through" do
14
+ expect(described_class.code("custom")).to eq("custom")
15
+ expect(described_class.code("")).to eq("")
16
+ expect(described_class.code(nil)).to be_nil
17
+ end
18
+ end
19
+
20
+ describe ".full_name" do
21
+ it "expands codes to full names" do
22
+ expect(described_class.full_name("ge")).to eq("general")
23
+ expect(described_class.full_name("TE")).to eq("technical")
24
+ expect(described_class.full_name("editorial")).to eq("editorial")
25
+ end
26
+
27
+ it "passes unrecognized values through" do
28
+ expect(described_class.full_name("Custom")).to eq("Custom")
29
+ expect(described_class.full_name(nil)).to be_nil
30
+ end
31
+ end
32
+
33
+ describe ".display_name" do
34
+ it "capitalizes recognized types for display" do
35
+ expect(described_class.display_name("ge")).to eq("General")
36
+ expect(described_class.display_name("technical")).to eq("Technical")
37
+ expect(described_class.display_name("ed")).to eq("Editorial")
38
+ end
39
+
40
+ it "defaults unknown or missing types" do
41
+ expect(described_class.display_name("unknown")).to eq("unknown")
42
+ expect(described_class.display_name(nil)).to eq("Unknown")
43
+ end
44
+ end
45
+ end
@@ -135,49 +135,82 @@ RSpec.describe Commenter::GitHubIssueCreator do
135
135
 
136
136
  describe "template variable generation" do
137
137
  let(:creator) { described_class.new(config_file.path, title_template_file.path, body_template_file.path) }
138
- let(:comment_sheet) { Commenter::CommentSheet.from_hash(yaml_data) }
139
- let(:comment) { comment_sheet.comments.first }
140
-
141
- it "generates correct template variables" do
142
- variables = creator.send(:template_variables, comment, comment_sheet)
143
-
144
- expect(variables["stage"]).to eq("DIS")
145
- expect(variables["document"]).to eq("Test Document")
146
- expect(variables["comment_id"]).to eq("US-001")
147
- expect(variables["type"]).to eq("technical")
148
- expect(variables["type_full_name"]).to eq("Technical")
149
- expect(variables["clause"]).to eq("5.1")
150
- expect(variables["element"]).to eq("Table 1")
151
- expect(variables["comments"]).to eq("Test comment text")
152
- expect(variables["brief_summary"]).to include("Clause 5.1")
153
- expect(variables["has_proposed_change"]).to be true
138
+ let(:title_template_file) do
139
+ file = Tempfile.new(["title", ".liquid"])
140
+ file.write("{{ stage }}|{{ document }}|{{ project }}|{{ version }}|{{ comment_id }}|{{ type }}|" \
141
+ "{{ type_full_name }}|{{ clause }}|{{ element }}|{{ line_number }}|" \
142
+ "{{ has_observations }}|{{ has_proposed_change }}|{{ locality_summary }}|{{ unique_id }}")
143
+ file.close
144
+ file
145
+ end
146
+
147
+ it "exposes sheet, comment, and computed variables to templates" do
148
+ result = creator.create_issues_from_yaml(yaml_file.path, dry_run: true).first
149
+ variables = result[:title].split("|")
150
+
151
+ expect(variables).to eq(
152
+ [
153
+ "DIS", "Test Document", "Test Project", "2012-03", "US-001", "technical",
154
+ "Technical", "5.1", "Table 1", "", "false", "true", "Clause 5.1, Table 1", "[DIS] US-001"
155
+ ]
156
+ )
154
157
  end
155
158
  end
156
159
 
157
160
  describe "label determination" do
158
161
  let(:creator) { described_class.new(config_file.path, title_template_file.path, body_template_file.path) }
159
- let(:comment_sheet) { Commenter::CommentSheet.from_hash(yaml_data) }
160
- let(:comment) { comment_sheet.comments.first }
161
162
 
162
- it "combines default, stage-specific, and comment type labels" do
163
- labels = creator.send(:determine_labels, comment, comment_sheet)
163
+ it "combines default, stage-specific, and comment type labels without duplicates" do
164
+ result = creator.create_issues_from_yaml(yaml_file.path, dry_run: true).first
165
+ labels = result[:labels]
164
166
 
165
- expect(labels).to include("comment-review") # default
166
- expect(labels).to include("draft-international-standard") # stage-specific
167
- expect(labels).to include("technical") # comment type (expanded)
168
- expect(labels.uniq).to eq(labels) # no duplicates
167
+ expect(labels).to include("comment-review", "draft-international-standard", "technical")
168
+ expect(labels.uniq).to eq(labels)
169
169
  end
170
170
  end
171
+ end
171
172
 
172
- describe "comment type expansion" do
173
- let(:creator) { described_class.new(config_file.path, title_template_file.path, body_template_file.path) }
173
+ RSpec.describe Commenter::GitHubIssueRetriever do
174
+ let(:config_file) do
175
+ file = Tempfile.new(["config", ".yaml"])
176
+ file.write({ "github" => { "repository" => "test-org/test-repo", "token" => "test-token" } }.to_yaml)
177
+ file.close
178
+ file
179
+ end
180
+
181
+ let(:osd_yaml_file) do
182
+ file = Tempfile.new(["comments", ".yaml"])
183
+ file.write({
184
+ "version" => "osd",
185
+ "document" => "ISO/DIS 5843-6(en)",
186
+ "stage" => "DIS",
187
+ "comments" => [
188
+ {
189
+ "id" => "1",
190
+ "body" => "John Doe",
191
+ "locality" => { "clause" => "5.2.1" },
192
+ "type" => "editorial",
193
+ "comments" => "The values in Table 3 are inconsistent."
194
+ }
195
+ ]
196
+ }.to_yaml)
197
+ file.close
198
+ file
199
+ end
200
+
201
+ after do
202
+ config_file.unlink
203
+ osd_yaml_file.unlink
204
+ end
205
+
206
+ describe "#retrieve_observations_from_yaml" do
207
+ it "rewrites the YAML with the schema header matching its version" do
208
+ retriever = described_class.new(config_file.path)
209
+
210
+ retriever.retrieve_observations_from_yaml(osd_yaml_file.path)
174
211
 
175
- it "expands comment type codes correctly" do
176
- expect(creator.send(:expand_comment_type, "ge")).to eq("General")
177
- expect(creator.send(:expand_comment_type, "te")).to eq("Technical")
178
- expect(creator.send(:expand_comment_type, "ed")).to eq("Editorial")
179
- expect(creator.send(:expand_comment_type, "unknown")).to eq("unknown")
180
- expect(creator.send(:expand_comment_type, nil)).to eq("Unknown")
212
+ expect(osd_yaml_file.open.read.lines.first)
213
+ .to eq("# yaml-language-server: $schema=schema/iso_comment_osd.yaml\n")
181
214
  end
182
215
  end
183
216
  end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "../support/osd_fixtures"
5
+
6
+ RSpec.describe Commenter::Parser::OsdXlsxParser do
7
+ include OsdFixtures
8
+
9
+ describe "resolved variant" do
10
+ it "extracts sheet metadata from the header rows" do
11
+ with_resolved_xlsx do |path|
12
+ sheet = described_class.new.parse(path)
13
+
14
+ expect(sheet.version).to eq("osd")
15
+ expect(sheet.date).to eq("2026-04-21")
16
+ expect(sheet.document).to eq("ISO/DIS 5843-6(en)")
17
+ expect(sheet.stage).to eq("DIS")
18
+ expect(sheet.project).to eq("ISO/DIS 5843-6")
19
+ expect(sheet.title_en).to eq("Aerodromes and heliports")
20
+ expect(sheet.title_fr).to eq("Aerodromes and heliports (FR)")
21
+ end
22
+ end
23
+
24
+ it "maps columns to comment attributes" do
25
+ with_resolved_xlsx do |path|
26
+ comment = described_class.new.parse(path).comments.first
27
+
28
+ expect(comment.id).to eq("1")
29
+ expect(comment.body).to eq("John Doe")
30
+ expect(comment.locality).to eq({ clause: "5.2.1", element: "Requirements" })
31
+ expect(comment.comments).to eq("The values in Table 3 are inconsistent.")
32
+ expect(comment.proposed_change).to eq("Correct the values in column 2.")
33
+ expect(comment.comment_type).to eq("Editorial")
34
+ expect(comment.resolution_status).to eq("Accepted")
35
+ expect(comment.resolution_date).to eq("2026-04-10")
36
+ expect(comment.feedbacks).to eq("Feedback from the working group")
37
+ expect(comment.motivation).to eq("Reviewed and approved by the working group.")
38
+ expect(comment.created_date).to eq("2026-04-01")
39
+ expect(comment.stage_code).to eq("40.20")
40
+ end
41
+ end
42
+
43
+ it "builds observations from resolution status and motivation" do
44
+ with_resolved_xlsx do |path|
45
+ comments = described_class.new.parse(path).comments
46
+
47
+ expect(comments.first.observations).to eq("Accepted. Reviewed and approved by the working group.")
48
+ expect(comments.last.observations).to eq("Rejected")
49
+ end
50
+ end
51
+
52
+ it "normalizes numeric comment ids to strings" do
53
+ with_resolved_xlsx do |path|
54
+ comments = described_class.new.parse(path).comments
55
+
56
+ expect(comments.map(&:id)).to eq(%w[1 2])
57
+ end
58
+ end
59
+
60
+ it "skips empty rows and keeps comments without clause data" do
61
+ with_resolved_xlsx do |path|
62
+ comments = described_class.new.parse(path).comments
63
+
64
+ expect(comments.length).to eq(2)
65
+ expect(comments.last.locality).to eq({})
66
+ expect(comments.last.proposed_change).to be_nil
67
+ end
68
+ end
69
+
70
+ it "falls back to proposal-on-text when proposed change is blank" do
71
+ rows = resolved_rows
72
+ rows[4][8] = nil # Proposed change
73
+ with_resolved_xlsx(rows) do |path|
74
+ sheet = described_class.new.parse(path)
75
+
76
+ expect(sheet.comments.first.proposed_change).to eq("Proposal on the text")
77
+ end
78
+ end
79
+
80
+ it "excludes observations when exclude_observations is set" do
81
+ with_resolved_xlsx do |path|
82
+ sheet = described_class.new.parse(path, exclude_observations: true)
83
+
84
+ expect(sheet.comments.first.observations).to be_nil
85
+ end
86
+ end
87
+
88
+ it "maps columns by real position when the header row has gaps" do
89
+ with_resolved_xlsx(resolved_rows(gap: true)) do |path|
90
+ comment = described_class.new.parse(path).comments.first
91
+
92
+ expect(comment.id).to eq("1")
93
+ expect(comment.type).to eq("editorial")
94
+ expect(comment.comments).to eq("The values in Table 3 are inconsistent.")
95
+ expect(comment.resolution_status).to eq("Accepted")
96
+ expect(comment.stage_code).to eq("40.20")
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+ RSpec.describe Commenter::Parser::OsdXlsxParser do
103
+ include OsdFixtures
104
+
105
+ describe "unresolved variant" do
106
+ it "parses the first sheet by default" do
107
+ with_unresolved_workbook do |path|
108
+ sheet = described_class.new.parse(path)
109
+
110
+ expect(sheet.comments.map(&:id)).to eq(%w[7 8])
111
+ expect(sheet.comments.first.body).to eq("John Doe")
112
+ expect(sheet.comments.first.type).to eq("editorial")
113
+ expect(sheet.comments.first.feedbacks).to eq("Reply from secretariat")
114
+ expect(sheet.comments.first.motivation).to eq("Justification text")
115
+ expect(sheet.comments.first.created_date).to eq("2026-04-05")
116
+ expect(sheet.comments.last.observations).to be_nil
117
+ end
118
+ end
119
+
120
+ it "uses the unresolved sheet when unresolved_only is set" do
121
+ with_unresolved_workbook do |path|
122
+ sheet = described_class.new.parse(path, unresolved_only: true)
123
+
124
+ expect(sheet.comments.map(&:id)).to eq(["9"])
125
+ expect(sheet.comments.first.type).to eq("general")
126
+ end
127
+ end
128
+
129
+ it "uses the resolved sheet when resolved_only is set" do
130
+ with_unresolved_workbook do |path|
131
+ sheet = described_class.new.parse(path, resolved_only: true)
132
+
133
+ expect(sheet.comments.map(&:id)).to eq(["10"])
134
+ expect(sheet.comments.first.observations).to eq("Partially accepted. Compromise reached.")
135
+ end
136
+ end
137
+
138
+ it "parses the sheet given by name" do
139
+ with_unresolved_workbook do |path|
140
+ sheet = described_class.new.parse(path, sheet: "Resolved comments (1)")
141
+
142
+ expect(sheet.comments.map(&:id)).to eq(["10"])
143
+ end
144
+ end
145
+
146
+ it "raises when the requested sheet does not exist" do
147
+ with_unresolved_workbook do |path|
148
+ expect { described_class.new.parse(path, sheet: "Nope") }
149
+ .to raise_error(/Sheet 'Nope' not found/)
150
+ end
151
+ end
152
+ end
153
+ end
154
+
155
+ RSpec.describe Commenter::Parser::OsdXlsxParser do
156
+ include OsdFixtures
157
+
158
+ describe "round-trip stability" do
159
+ it "produces identical YAML when reloaded from its own output" do
160
+ with_resolved_xlsx do |path|
161
+ sheet = described_class.new.parse(path)
162
+ reparsed = Commenter::CommentSheet.from_hash(sheet.to_yaml_h)
163
+
164
+ expect(reparsed.to_yaml_h).to eq(sheet.to_yaml_h)
165
+ end
166
+ end
167
+
168
+ it "omits nil titles from YAML output" do
169
+ rows = resolved_rows
170
+ rows[1] = ["2026-04-21", "ISO/CD 12345(en)"]
171
+ with_resolved_xlsx(rows) do |path|
172
+ sheet = described_class.new.parse(path)
173
+
174
+ expect(sheet.stage).to eq("CD")
175
+ expect(sheet.to_yaml_h).not_to have_key("title_en")
176
+ expect(sheet.to_yaml_h).not_to have_key("title_fr")
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "../support/redline_docx_builder"
5
+
6
+ RSpec.describe Commenter::Parser::TrackChangeDocxParser do
7
+ def author = "CHEN Yvonne"
8
+
9
+ def change(kind, id, text, date: "2025-12-05T20:10:00Z")
10
+ { change: kind, id: id, author: author, date: date, text: text }
11
+ end
12
+
13
+ def with_redline(paragraphs, comments: [], **options)
14
+ file = Tempfile.new(%w[redline .docx])
15
+ RedlineDocxBuilder.write(file.path, paragraphs, comments: comments)
16
+ yield described_class.new.parse(file.path, options)
17
+ ensure
18
+ file.close
19
+ file.unlink
20
+ end
21
+
22
+ let(:base_paragraphs) do
23
+ [
24
+ { change: :ins, id: "1", author: author, date: "2025-12-05T20:10:00Z", text: "ISO boilerplate" },
25
+ { heading: true, level: 1, text: "4 Basic principles" },
26
+ { change: :del, id: "2", author: author, date: "2025-12-05T20:11:00Z", text: "old text" },
27
+ { heading: true, level: 2, text: "4.2.1Thermodynamic temperature tangent" },
28
+ { marker: true, id: "9", author: author },
29
+ { parts: [{ text: "Body " }, change(:ins, "3", "inserted")] },
30
+ { heading: true, level: 1, text: "Annex A" },
31
+ { change: :moveTo, id: "4", author: author, date: "2025-12-06T10:00:00Z", text: "moved" }
32
+ ]
33
+ end
34
+
35
+ it "extracts track changes in document order with rendered proposed_change" do
36
+ with_redline(base_paragraphs) do |sheet|
37
+ comments = sheet.comments
38
+
39
+ expect(comments.map(&:proposed_change)).to eq([
40
+ 'Insert: "ISO boilerplate"',
41
+ 'Delete: "old text"',
42
+ 'Insert: "inserted"',
43
+ 'Move to: "moved"'
44
+ ])
45
+ expect(comments.map(&:comments)).to eq([
46
+ "Track change (insertion)",
47
+ "Track change (deletion)",
48
+ "Track change (insertion)",
49
+ "Track change (move (to))"
50
+ ])
51
+ end
52
+ end
53
+
54
+ it "assigns the clause of the nearest preceding heading, including sub-clauses" do
55
+ with_redline(base_paragraphs) do |sheet|
56
+ expect(sheet.comments.map(&:clause)).to eq(["_whole document", "4", "4.2.1", "Annex A"])
57
+ end
58
+ end
59
+
60
+ it "skips self-closing paragraph-mark markers without corrupting later capture" do
61
+ with_redline(base_paragraphs) do |sheet|
62
+ expect(sheet.comments.length).to eq(4)
63
+ end
64
+ end
65
+
66
+ it "captures nested track changes" do
67
+ paragraphs = [
68
+ { change: :ins, id: "1", author: author, date: "2025-12-05T20:10:00Z", text: "",
69
+ nested: [change(:ins, "2", "inner text")] }
70
+ ]
71
+
72
+ with_redline(paragraphs) do |sheet|
73
+ # The inner change ends first, so it is emitted before its wrapper.
74
+ expect(sheet.comments.map(&:proposed_change)).to eq(['Insert: "inner text"', 'Insert: ""'])
75
+ end
76
+ end
77
+
78
+ it "builds CS-prefixed sequential IDs by default" do
79
+ with_redline(base_paragraphs) do |sheet|
80
+ expect(sheet.comments.map(&:id)).to eq(%w[CS-001 CS-002 CS-003 CS-004])
81
+ expect(sheet.comments.map(&:body).uniq).to eq(["CS"])
82
+ end
83
+ end
84
+
85
+ it "uses a custom body code and technical type" do
86
+ with_redline(base_paragraphs, body: "GB", type: "ge") do |sheet|
87
+ expect(sheet.comments.first.id).to eq("GB-001")
88
+ expect(sheet.comments.first.type).to eq("general")
89
+ end
90
+ end
91
+
92
+ it "stamps observations on every entry when given" do
93
+ with_redline(base_paragraphs, observations: "Accepted. ISO/CS tracked change accepted.") do |sheet|
94
+ expect(sheet.comments.map(&:observations).uniq)
95
+ .to eq(["Accepted. ISO/CS tracked change accepted."])
96
+ end
97
+ end
98
+
99
+ it "leaves observations blank by default and honours exclude_observations" do
100
+ with_redline(base_paragraphs) do |sheet|
101
+ expect(sheet.comments.first.observations).to be_nil
102
+ end
103
+
104
+ with_redline(base_paragraphs, observations: "Accepted.", exclude_observations: true) do |sheet|
105
+ expect(sheet.comments.first.observations).to be_nil
106
+ end
107
+ end
108
+
109
+ it "builds 2012-03 sheet metadata from the changes and options" do
110
+ with_redline(base_paragraphs, document: "ISO 2533:2026", stage: "DIS") do |sheet|
111
+ expect(sheet.version).to eq("2012-03")
112
+ expect(sheet.schema_name).to eq("iso_comment_2012-03.yaml")
113
+ expect(sheet.document).to eq("ISO 2533:2026")
114
+ expect(sheet.stage).to eq("DIS")
115
+ expect(sheet.date).to eq("2025-12-06")
116
+ end
117
+ end
118
+
119
+ it "is reachable through Parser dispatch with format redline" do
120
+ file = Tempfile.new(%w[redline .docx])
121
+ RedlineDocxBuilder.write(file.path, base_paragraphs)
122
+
123
+ sheet = Commenter::Parser.new.parse(file.path, format: "redline")
124
+ expect(sheet.comments.length).to eq(4)
125
+ ensure
126
+ file.close
127
+ file.unlink
128
+ end
129
+
130
+ describe "element locality" do
131
+ def table_paragraphs
132
+ [
133
+ { heading: true, level: 1, text: "4 Basic principles" },
134
+ { text: "Table 3 — Atmospheric properties at altitude" },
135
+ { parts: [{ text: "Body " }, change(:ins, "5", "edited")] },
136
+ { text: "Formula (9):" },
137
+ { change: :del, id: "6", author: author, date: "2025-12-05T20:10:00Z", text: "old formula text" },
138
+ { text: "NOTE 2 — This note provides clarification." },
139
+ { parts: [{ text: "note body " }, change(:ins, "7", "edited")] }
140
+ ]
141
+ end
142
+
143
+ it "resolves the element from caption paragraphs and carries it to following changes" do
144
+ with_redline(table_paragraphs) do |sheet|
145
+ expect(sheet.comments.map(&:element)).to eq(["Table 3", "Formula (9)", "NOTE 2"])
146
+ end
147
+ end
148
+
149
+ it "resolves inline element mentions" do
150
+ paragraphs = [
151
+ { heading: true, level: 1, text: "5 Data tables" },
152
+ { parts: [{ text: "The values in Table 5 shall be " }, change(:ins, "8", "interpreted thus")] }
153
+ ]
154
+
155
+ with_redline(paragraphs) do |sheet|
156
+ expect(sheet.comments.first.element).to eq("Table 5")
157
+ end
158
+ end
159
+
160
+ it "resolves annex figure captions" do
161
+ paragraphs = [
162
+ { heading: true, level: 1, text: "Annex A" },
163
+ { text: "Figure A.2 — Temperature profile" },
164
+ { change: :ins, id: "9", author: author, date: "2025-12-05T20:10:00Z", text: "new caption text" }
165
+ ]
166
+
167
+ with_redline(paragraphs) do |sheet|
168
+ expect(sheet.comments.first.element).to eq("Figure A.2")
169
+ end
170
+ end
171
+
172
+ it "resets the element when the clause changes" do
173
+ paragraphs = table_paragraphs + [
174
+ { heading: true, level: 1, text: "5 Data tables" },
175
+ { parts: [{ text: "Plain body " }, change(:ins, "10", "edited")] }
176
+ ]
177
+
178
+ with_redline(paragraphs) do |sheet|
179
+ expect(sheet.comments.last.element).to be_nil
180
+ end
181
+ end
182
+ end
183
+
184
+ describe "unnumbered sub-headings" do
185
+ it "inherit the parent heading clause" do
186
+ paragraphs = [
187
+ { heading: true, level: 1, text: "4 Basic principles" },
188
+ { heading: true, level: 2, text: "4.2 Temperature" },
189
+ { heading: true, level: 3, text: "Justification" },
190
+ { parts: [{ text: "Body " }, change(:ins, "11", "edited")] }
191
+ ]
192
+
193
+ with_redline(paragraphs) do |sheet|
194
+ expect(sheet.comments.first.clause).to eq("4.2")
195
+ end
196
+ end
197
+ end
198
+
199
+ describe "reviewer comment threads" do
200
+ let(:remark_paragraphs) do
201
+ [
202
+ { change: :ins, id: "1", author: author, date: "2025-12-05T20:10:00Z", text: "tracked" },
203
+ { heading: true, level: 1, text: "1 Scope" },
204
+ { anchor: "74", text: "Values in feet." },
205
+ { heading: true, level: 1, text: "2 Normative references" },
206
+ { anchor: "88", text: "Introduction paragraph." }
207
+ ]
208
+ end
209
+
210
+ let(:remarks) do
211
+ [
212
+ { id: "74", author: author, date: "2025-12-05T20:10:00Z",
213
+ text: "Please provide values in km. Equivalent values in ft can be provided in brackets." },
214
+ { id: "88", author: author, date: "2025-12-05T20:12:00Z",
215
+ text: "Remove this paragraph from the Introduction." }
216
+ ]
217
+ end
218
+
219
+ it "emits remarks after track changes with -CNNN ids and verbatim text" do
220
+ with_redline(remark_paragraphs, comments: remarks) do |sheet|
221
+ expect(sheet.comments.length).to eq(3)
222
+
223
+ remark = sheet.comments.last
224
+ expect(sheet.comments[1].id).to eq("CS-C001")
225
+ expect(remark.id).to eq("CS-C002")
226
+ expect(remark.comments).to eq("Remove this paragraph from the Introduction.")
227
+ end
228
+ end
229
+
230
+ it "rewords the remark into proposed_change" do
231
+ with_redline(remark_paragraphs, comments: remarks) do |sheet|
232
+ expect(sheet.comments[1].proposed_change)
233
+ .to eq("Provide values in km. Equivalent values in ft can be provided in brackets.")
234
+ end
235
+ end
236
+
237
+ it "leaves remark observations empty for the owner to draft" do
238
+ with_redline(remark_paragraphs, comments: remarks) do |sheet|
239
+ expect(sheet.comments[1].observations).to be_nil
240
+ end
241
+ end
242
+
243
+ it "anchors remarks to the clause of their commentRangeStart" do
244
+ with_redline(remark_paragraphs, comments: remarks) do |sheet|
245
+ expect(sheet.comments[1].clause).to eq("1")
246
+ expect(sheet.comments.last.clause).to eq("2")
247
+ end
248
+ end
249
+
250
+ it "defaults unanchored remarks to _whole document" do
251
+ unanchored = [{ id: "99", author: author, date: "2025-12-05T20:10:00Z", text: "General remark." }]
252
+
253
+ with_redline([{ text: "Body" }], comments: unanchored) do |sheet|
254
+ expect(sheet.comments.first.clause).to eq("_whole document")
255
+ end
256
+ end
257
+
258
+ it "stamps accept_all on track changes but not on remarks" do
259
+ with_redline(remark_paragraphs, accept_all: true, comments: remarks) do |sheet|
260
+ expect(sheet.comments.first.observations).to eq("Accepted. Tracked change accepted.")
261
+ expect(sheet.comments[1].observations).to be_nil
262
+ end
263
+ end
264
+
265
+ it "prefers an explicit observations stamp over accept_all" do
266
+ with_redline(remark_paragraphs, accept_all: true, observations: "Custom.", comments: remarks) do |sheet|
267
+ expect(sheet.comments.first.observations).to eq("Custom.")
268
+ end
269
+ end
270
+
271
+ it "handles documents without comments.xml content" do
272
+ with_redline([{ text: "No changes, no remarks." }]) do |sheet|
273
+ expect(sheet.comments).to be_empty
274
+ end
275
+ end
276
+ end
277
+ end