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.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -0
- data/CLAUDE.md +17 -7
- data/CONTEXT.md +27 -0
- data/README.adoc +92 -17
- data/commenter.gemspec +1 -0
- data/lib/commenter/ballot.rb +67 -0
- data/lib/commenter/ballot_report.rb +60 -0
- data/lib/commenter/cli.rb +62 -43
- data/lib/commenter/comment.rb +103 -126
- data/lib/commenter/comment_locality.rb +18 -0
- data/lib/commenter/comment_sheet.rb +31 -63
- data/lib/commenter/comment_type.rb +39 -0
- data/lib/commenter/disposition_status.rb +37 -0
- data/lib/commenter/filler.rb +91 -59
- data/lib/commenter/github_info.rb +23 -0
- data/lib/commenter/github_integration.rb +17 -71
- data/lib/commenter/github_session.rb +35 -0
- data/lib/commenter/parser/osd_xlsx_parser.rb +1 -12
- data/lib/commenter/parser/track_change_docx_parser.rb +330 -0
- data/lib/commenter/parser.rb +10 -13
- data/lib/commenter/version.rb +1 -1
- data/lib/commenter.rb +16 -5
- data/spec/commenter/ballot_report_spec.rb +62 -0
- data/spec/commenter/ballot_spec.rb +68 -0
- data/spec/commenter/cli_spec.rb +41 -0
- data/spec/commenter/comment_sheet_spec.rb +35 -10
- data/spec/commenter/comment_spec.rb +22 -9
- data/spec/commenter/comment_type_spec.rb +45 -0
- data/spec/commenter/disposition_status_spec.rb +40 -0
- data/spec/commenter/filler_spec.rb +119 -0
- data/spec/commenter/github_integration_spec.rb +65 -32
- data/spec/commenter/osd_xlsx_parser_spec.rb +3 -2
- data/spec/commenter/track_change_docx_parser_spec.rb +277 -0
- data/spec/support/redline_docx_builder.rb +95 -0
- data/spec/support/xlsx_builder.rb +17 -12
- 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: "
|
|
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: "
|
|
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[
|
|
174
|
-
expect(hash[
|
|
175
|
-
expect(hash[
|
|
176
|
-
expect(hash[
|
|
177
|
-
expect(hash[
|
|
178
|
-
expect(hash[
|
|
179
|
-
expect(hash[
|
|
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&B <standard>")
|
|
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(:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
expect(variables
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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"
|
|
166
|
-
expect(labels).to
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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(
|
|
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
|
|
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
|