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.
- checksums.yaml +4 -4
- data/.rubocop.yml +8 -0
- data/CLAUDE.md +68 -0
- data/README.adoc +115 -14
- data/commenter.gemspec +3 -2
- data/lib/commenter/cli.rb +23 -25
- data/lib/commenter/comment.rb +23 -8
- data/lib/commenter/comment_sheet.rb +18 -2
- data/lib/commenter/comment_type.rb +35 -0
- data/lib/commenter/github_integration.rb +11 -60
- data/lib/commenter/github_session.rb +35 -0
- data/lib/commenter/parser/osd_xlsx_parser.rb +243 -0
- data/lib/commenter/parser/track_change_docx_parser.rb +332 -0
- data/lib/commenter/parser.rb +93 -37
- data/lib/commenter/version.rb +1 -1
- data/lib/commenter.rb +5 -0
- data/schema/iso_comment_2012-03.yaml +2 -2
- data/schema/iso_comment_osd.yaml +112 -0
- data/spec/commenter/cli_spec.rb +35 -0
- data/spec/commenter/comment_sheet_spec.rb +25 -0
- data/spec/commenter/comment_type_spec.rb +45 -0
- data/spec/commenter/github_integration_spec.rb +65 -32
- data/spec/commenter/osd_xlsx_parser_spec.rb +180 -0
- data/spec/commenter/track_change_docx_parser_spec.rb +277 -0
- data/spec/support/osd_fixtures.rb +94 -0
- data/spec/support/redline_docx_builder.rb +88 -0
- data/spec/support/xlsx_builder.rb +147 -0
- metadata +32 -5
|
@@ -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(:
|
|
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
|
|
@@ -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
|