commenter 0.3.0 → 0.4.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/CLAUDE.md +17 -7
  4. data/CONTEXT.md +27 -0
  5. data/README.adoc +92 -17
  6. data/commenter.gemspec +1 -0
  7. data/lib/commenter/ballot.rb +67 -0
  8. data/lib/commenter/ballot_report.rb +60 -0
  9. data/lib/commenter/cli.rb +62 -43
  10. data/lib/commenter/comment.rb +103 -126
  11. data/lib/commenter/comment_locality.rb +18 -0
  12. data/lib/commenter/comment_sheet.rb +31 -63
  13. data/lib/commenter/comment_type.rb +39 -0
  14. data/lib/commenter/disposition_status.rb +37 -0
  15. data/lib/commenter/filler.rb +91 -59
  16. data/lib/commenter/github_info.rb +23 -0
  17. data/lib/commenter/github_integration.rb +17 -71
  18. data/lib/commenter/github_session.rb +35 -0
  19. data/lib/commenter/parser/osd_xlsx_parser.rb +1 -12
  20. data/lib/commenter/parser/track_change_docx_parser.rb +330 -0
  21. data/lib/commenter/parser.rb +10 -13
  22. data/lib/commenter/version.rb +1 -1
  23. data/lib/commenter.rb +16 -5
  24. data/spec/commenter/ballot_report_spec.rb +62 -0
  25. data/spec/commenter/ballot_spec.rb +68 -0
  26. data/spec/commenter/cli_spec.rb +41 -0
  27. data/spec/commenter/comment_sheet_spec.rb +35 -10
  28. data/spec/commenter/comment_spec.rb +22 -9
  29. data/spec/commenter/comment_type_spec.rb +45 -0
  30. data/spec/commenter/disposition_status_spec.rb +40 -0
  31. data/spec/commenter/filler_spec.rb +119 -0
  32. data/spec/commenter/github_integration_spec.rb +65 -32
  33. data/spec/commenter/osd_xlsx_parser_spec.rb +3 -2
  34. data/spec/commenter/track_change_docx_parser_spec.rb +277 -0
  35. data/spec/support/redline_docx_builder.rb +95 -0
  36. data/spec/support/xlsx_builder.rb +17 -12
  37. metadata +32 -2
@@ -9,7 +9,7 @@ RSpec.describe Commenter::Comment do
9
9
  id: "US-001",
10
10
  body: "US",
11
11
  locality: { clause: "5.1", element: "Table 1", line_number: "42" },
12
- type: "te",
12
+ type: "technical",
13
13
  comments: "Test comment",
14
14
  proposed_change: "Test change",
15
15
  observations: "Test observations"
@@ -28,6 +28,19 @@ RSpec.describe Commenter::Comment do
28
28
  expect(comment.observations).to eq("Test observations")
29
29
  end
30
30
 
31
+ it "expands short type codes when loaded from YAML" do
32
+ yaml = <<~YAML
33
+ ---
34
+ id: US-009
35
+ type: te
36
+ YAML
37
+
38
+ comment = described_class.from_yaml(yaml)
39
+
40
+ expect(comment.id).to eq("US-009")
41
+ expect(comment.type).to eq("technical")
42
+ end
43
+
31
44
  it "handles string keys in attributes" do
32
45
  attributes = {
33
46
  "id" => "US-001",
@@ -162,7 +175,7 @@ RSpec.describe Commenter::Comment do
162
175
  id: "US-001",
163
176
  body: "US",
164
177
  locality: { clause: "5.1" },
165
- type: "te",
178
+ type: "technical",
166
179
  comments: "Test",
167
180
  proposed_change: "Change",
168
181
  observations: "Obs"
@@ -170,13 +183,13 @@ RSpec.describe Commenter::Comment do
170
183
 
171
184
  hash = comment.to_h
172
185
 
173
- expect(hash[:id]).to eq("US-001")
174
- expect(hash[:body]).to eq("US")
175
- expect(hash[:locality][:clause]).to eq("5.1")
176
- expect(hash[:type]).to eq("technical")
177
- expect(hash[:comments]).to eq("Test")
178
- expect(hash[:proposed_change]).to eq("Change")
179
- expect(hash[:observations]).to eq("Obs")
186
+ expect(hash["id"]).to eq("US-001")
187
+ expect(hash["body"]).to eq("US")
188
+ expect(hash["locality"]["clause"]).to eq("5.1")
189
+ expect(hash["type"]).to eq("technical")
190
+ expect(hash["comments"]).to eq("Test")
191
+ expect(hash["proposed_change"]).to eq("Change")
192
+ expect(hash["observations"]).to eq("Obs")
180
193
  end
181
194
  end
182
195
 
@@ -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
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Commenter::DispositionStatus do
6
+ describe ".match" do
7
+ it "classifies accepted dispositions" do
8
+ expect(described_class.match("Accepted")).to eq(:accepted)
9
+ expect(described_class.match("accepted.")).to eq(:accepted)
10
+ expect(described_class.match("Partially accepted")).to eq(:accepted)
11
+ end
12
+
13
+ it "classifies accept-with-modifications before the bare accept pattern" do
14
+ expect(described_class.match("Accept with modifications")).to eq(:accept_with_modifications)
15
+ expect(described_class.match("Accepted with modifications")).to eq(:accept_with_modifications)
16
+ expect(described_class.match("Accepted, with changes")).to eq(:accept_with_modifications)
17
+ expect(described_class.match("AWM")).to eq(:accept_with_modifications)
18
+ end
19
+
20
+ it "classifies rejection including the 'not accepted' phrasing" do
21
+ expect(described_class.match("Rejected")).to eq(:rejected)
22
+ expect(described_class.match("Not accepted")).to eq(:rejected)
23
+ end
24
+
25
+ it "classifies noted, todo, and non-matching text" do
26
+ expect(described_class.match("Noted")).to eq(:noted)
27
+ expect(described_class.match("TODO: review")).to eq(:todo)
28
+ expect(described_class.match("Deferred to next revision")).to be_nil
29
+ expect(described_class.match("")).to be_nil
30
+ expect(described_class.match(nil)).to be_nil
31
+ end
32
+ end
33
+
34
+ describe ".statuses" do
35
+ it "lists the canonical statuses" do
36
+ expect(described_class.statuses)
37
+ .to eq(%i[accept_with_modifications rejected accepted noted todo])
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "nokogiri"
5
+ require "zip"
6
+
7
+ RSpec.describe Commenter::Filler do
8
+ let(:template_path) { File.expand_path("../../data/iso_comment_template_2012-03.docx", __dir__) }
9
+
10
+ def comment(attrs)
11
+ Commenter::Comment.new({ body: "US", type: "technical", comments: "Text" }.merge(attrs))
12
+ end
13
+
14
+ def header_xml(output_path)
15
+ Zip::File.open(output_path) do |zip|
16
+ zip.entries.find { |entry| entry.name == "word/header2.xml" }.get_input_stream(&:read)
17
+ end
18
+ end
19
+
20
+ def body_xml(output_path)
21
+ Zip::File.open(output_path) do |zip|
22
+ zip.entries.find { |entry| entry.name == "word/document.xml" }.get_input_stream(&:read)
23
+ end
24
+ end
25
+
26
+ # Rows of the comment table as arrays of cell text. Read via Nokogiri over
27
+ # the extracted XML rather than Docx::Document, whose open zip handle keeps
28
+ # the file locked on Windows and breaks Dir.mktmpdir teardown.
29
+ def body_rows(output_path)
30
+ document = Nokogiri::XML(body_xml(output_path))
31
+ document.xpath("//w:tbl").flat_map { |table| table.xpath("./w:tr") }.map do |row|
32
+ row.xpath("./w:tc").map { |cell| cell.xpath(".//w:t").map(&:text).join }
33
+ end
34
+ end
35
+
36
+ it "writes comment rows into the template table" do
37
+ Dir.mktmpdir do |dir|
38
+ output = File.join(dir, "filled.docx")
39
+ described_class.new.fill(template_path, output, [
40
+ comment(id: "US-001", locality: { clause: "5.2.1", element: "Table 3", line_number: "45" },
41
+ proposed_change: "Fix them", observations: "Accepted")
42
+ ])
43
+
44
+ row = body_rows(output).first
45
+ expect(row[0]).to include("US-001")
46
+ expect(row[1]).to include("45")
47
+ expect(row[2]).to include("5.2.1")
48
+ expect(row[3]).to include("Table 3")
49
+ expect(row[4]).to include("technical")
50
+ expect(row[5]).to include("Text")
51
+ expect(row[6]).to include("Fix them")
52
+ expect(row[7]).to include("Accepted")
53
+ end
54
+ end
55
+
56
+ it "keeps multiple comments in input order and accepts plain hashes" do
57
+ Dir.mktmpdir do |dir|
58
+ output = File.join(dir, "filled.docx")
59
+ described_class.new.fill(template_path, output, [
60
+ { "id" => "DE-001", "comments" => "First" },
61
+ comment(id: "US-001", comments: "Second")
62
+ ])
63
+
64
+ rows = body_rows(output)
65
+ expect(rows.length).to eq(2)
66
+ expect(rows[0][0]).to include("DE-001")
67
+ expect(rows[1][0]).to include("US-001")
68
+ end
69
+ end
70
+
71
+ it "fills the header metadata labels in the page header" do
72
+ Dir.mktmpdir do |dir|
73
+ output = File.join(dir, "filled.docx")
74
+ described_class.new.fill(template_path, output, [comment(id: "US-001")],
75
+ date: "2026-08-28", document: "ISO 2533:2026", project: "Ballot review")
76
+
77
+ header = header_xml(output)
78
+ expect(header).to include("Date: 2026-08-28")
79
+ expect(header).to include("Document: ISO 2533:2026")
80
+ expect(header).to include("Project: Ballot review")
81
+ end
82
+ end
83
+
84
+ it "leaves header labels untouched when no metadata is given" do
85
+ Dir.mktmpdir do |dir|
86
+ output = File.join(dir, "filled.docx")
87
+ described_class.new.fill(template_path, output, [comment(id: "US-001")])
88
+
89
+ header = header_xml(output)
90
+ expect(header).to include(">Date: <")
91
+ expect(header).to include(">Document:<")
92
+ expect(header).to include(">Project:<")
93
+ end
94
+ end
95
+
96
+ it "escapes XML special characters in metadata values" do
97
+ Dir.mktmpdir do |dir|
98
+ output = File.join(dir, "filled.docx")
99
+ described_class.new.fill(template_path, output, [comment(id: "US-001")],
100
+ document: "A&B <standard>")
101
+
102
+ expect(header_xml(output)).to include("Document: A&amp;B &lt;standard&gt;")
103
+ end
104
+ end
105
+
106
+ it "shades observation cells according to disposition status" do
107
+ Dir.mktmpdir do |dir|
108
+ output = File.join(dir, "filled.docx")
109
+ described_class.new.fill(template_path, output, [
110
+ comment(id: "US-001", observations: "Accepted"),
111
+ comment(id: "US-002", observations: "Not accepted")
112
+ ], shading: true)
113
+
114
+ xml = body_xml(output)
115
+ expect(xml).to include('w:fill="92D050"')
116
+ expect(xml).to include('w:fill="FF99CC"')
117
+ end
118
+ end
119
+ 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
@@ -27,7 +27,8 @@ RSpec.describe Commenter::Parser::OsdXlsxParser do
27
27
 
28
28
  expect(comment.id).to eq("1")
29
29
  expect(comment.body).to eq("John Doe")
30
- expect(comment.locality).to eq({ clause: "5.2.1", element: "Requirements" })
30
+ expect(comment.locality.clause).to eq("5.2.1")
31
+ expect(comment.locality.element).to eq("Requirements")
31
32
  expect(comment.comments).to eq("The values in Table 3 are inconsistent.")
32
33
  expect(comment.proposed_change).to eq("Correct the values in column 2.")
33
34
  expect(comment.comment_type).to eq("Editorial")
@@ -62,7 +63,7 @@ RSpec.describe Commenter::Parser::OsdXlsxParser do
62
63
  comments = described_class.new.parse(path).comments
63
64
 
64
65
  expect(comments.length).to eq(2)
65
- expect(comments.last.locality).to eq({})
66
+ expect(comments.last.locality.clause).to be_nil
66
67
  expect(comments.last.proposed_change).to be_nil
67
68
  end
68
69
  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