commenter 0.3.1 → 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.
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Commenter
6
+ # Location of a comment within the reviewed document.
7
+ class CommentLocality < Lutaml::Model::Serializable
8
+ attribute :line_number, :string
9
+ attribute :clause, :string
10
+ attribute :element, :string
11
+
12
+ yaml do
13
+ map "line_number", to: :line_number
14
+ map "clause", to: :clause
15
+ map "element", to: :element
16
+ end
17
+ end
18
+ end
@@ -1,48 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "comment"
3
+ require "lutaml/model"
4
4
 
5
5
  module Commenter
6
- class CommentSheet
7
- attr_accessor :version, :date, :document, :project, :stage, :comments, :title_en, :title_fr
8
-
9
- def initialize(attributes = {})
10
- # Normalize input to symbols
11
- attrs = symbolize_keys(attributes)
12
-
13
- @version = attrs[:version] || "2012-03"
14
- @date = attrs[:date]
15
- @document = attrs[:document]
16
- @project = attrs[:project]
17
- @stage = attrs[:stage]
18
- @title_en = attrs[:title_en]
19
- @title_fr = attrs[:title_fr]
20
- @comments = (attrs[:comments] || []).map { |c| c.is_a?(Comment) ? c : Comment.from_hash(c) }
6
+ # One ballot submission: metadata plus its comments. Serialization is
7
+ # declared with lutaml-model.
8
+ class CommentSheet < Lutaml::Model::Serializable
9
+ attribute :version, :string, default: -> { "2012-03" }
10
+ attribute :date, :string
11
+ attribute :document, :string
12
+ attribute :project, :string
13
+ attribute :stage, :string
14
+ attribute :title_en, :string
15
+ attribute :title_fr, :string
16
+ attribute :comments, Comment, collection: true
17
+
18
+ yaml do
19
+ map "version", to: :version
20
+ map "date", to: :date
21
+ map "document", to: :document
22
+ map "project", to: :project
23
+ map "stage", to: :stage
24
+ map "title_en", to: :title_en
25
+ map "title_fr", to: :title_fr
26
+ map "comments", to: :comments
21
27
  end
22
28
 
23
29
  def add_comment(comment)
24
- @comments << (comment.is_a?(Comment) ? comment : Comment.from_hash(comment))
25
- end
26
-
27
- def to_h
28
- {
29
- version: @version,
30
- date: @date,
31
- document: @document,
32
- project: @project,
33
- stage: @stage,
34
- title_en: @title_en,
35
- title_fr: @title_fr,
36
- comments: @comments.map(&:to_h)
37
- }
38
- end
39
-
40
- def to_yaml_h
41
- hash = to_h.merge(comments: @comments.map(&:to_yaml_h))
42
- # Remove nil-valued keys for cleaner YAML output
43
- hash.delete(:title_en) if hash[:title_en].nil?
44
- hash.delete(:title_fr) if hash[:title_fr].nil?
45
- stringify_keys(hash)
30
+ self.comments = (comments || []) + [comment.is_a?(Comment) ? comment : Comment.from_hash(comment)]
46
31
  end
47
32
 
48
33
  def schema_name
@@ -50,40 +35,15 @@ module Commenter
50
35
  end
51
36
 
52
37
  def to_yaml_document(schema_dir = "schema")
53
- "# yaml-language-server: $schema=#{File.join(schema_dir.to_s, schema_name)}\n\n#{to_yaml_h.to_yaml}"
38
+ "# yaml-language-server: $schema=#{File.join(schema_dir.to_s, schema_name)}\n\n#{to_yaml}"
54
39
  end
55
40
 
56
- def self.from_hash(hash)
57
- new(hash)
58
- end
59
-
60
- private
61
-
62
- def symbolize_keys(hash)
63
- return hash unless hash.is_a?(Hash)
64
-
65
- hash.each_with_object({}) do |(key, value), result|
66
- new_key = key.to_sym
67
- new_value = value.is_a?(Hash) ? symbolize_keys(value) : value
68
- result[new_key] = new_value
69
- end
41
+ def to_yaml_h
42
+ CommentSheet.to_hash(self)
70
43
  end
71
44
 
72
- def stringify_keys(hash)
73
- return hash unless hash.is_a?(Hash)
74
-
75
- hash.each_with_object({}) do |(key, value), result|
76
- new_key = key.to_s
77
- new_value = case value
78
- when Hash
79
- stringify_keys(value)
80
- when Array
81
- value.map { |item| item.is_a?(Hash) ? stringify_keys(item) : item }
82
- else
83
- value
84
- end
85
- result[new_key] = new_value
86
- end
45
+ def to_h
46
+ CommentSheet.to_hash(self)
87
47
  end
88
48
  end
89
49
  end
@@ -25,7 +25,11 @@ module Commenter
25
25
  end
26
26
 
27
27
  def full_name(type)
28
- FULL_NAMES.fetch(type.to_s.strip.downcase) { type&.to_s&.strip }
28
+ return nil if type.nil?
29
+
30
+ value = type.to_s.strip
31
+ downcased = value.downcase
32
+ FULL_NAMES[downcased] || (FULL_NAMES.values.include?(downcased) ? downcased : value)
29
33
  end
30
34
 
31
35
  def display_name(type)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commenter
4
+ # Canonical disposition vocabulary, shared by cell shading (Filler) and
5
+ # ballot statistics (BallotReport). Matches free-text observations and
6
+ # OSD's structured resolution_status values. One home for the question
7
+ # "what counts as an accepted disposition".
8
+ module DispositionStatus
9
+ PATTERNS = [
10
+ [:accept_with_modifications, /awm|accept(ed)?[ ,]*with +(modifications|changes)/].freeze,
11
+ [:rejected, /reject(ed)?|not accepted/].freeze,
12
+ [:accepted, /accept(ed)?/].freeze,
13
+ [:noted, /noted/].freeze,
14
+ [:todo, /todo/].freeze
15
+ ].freeze
16
+
17
+ module_function
18
+
19
+ # Returns the canonical status symbol for the given text, or nil when
20
+ # nothing matches. Order matters: "accept with modifications" and
21
+ # "not accepted" must be classified before the bare "accept" pattern.
22
+ def match(text)
23
+ value = text.to_s.downcase.strip
24
+ return nil if value.empty?
25
+
26
+ PATTERNS.each do |status, pattern|
27
+ return status if value.match?(pattern)
28
+ end
29
+
30
+ nil
31
+ end
32
+
33
+ def statuses
34
+ PATTERNS.map(&:first)
35
+ end
36
+ end
37
+ end
@@ -1,9 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "docx"
4
+ require "zip"
4
5
 
5
6
  module Commenter
6
7
  class Filler
8
+ # Labels of the sheet metadata fields in the template's page header
9
+ # (word/header*.xml). Values supplied to #fill are appended after each
10
+ # label; the docx gem cannot write header parts (see docx PR #73), so
11
+ # this is done by rewriting the entry directly.
12
+ HEADER_METADATA = { date: "Date", document: "Document", project: "Project" }.freeze
13
+
7
14
  def fill(template_path, output_path, comments, options = {})
8
15
  doc = Docx::Document.open(template_path)
9
16
  table = doc.tables.first
@@ -11,48 +18,88 @@ module Commenter
11
18
  raise "No table found in template" unless table
12
19
  raise "Template table must have at least one row" if table.row_count < 1
13
20
 
14
- # Get the template row (first row in the table)
15
- template_row = table.rows.first
16
-
17
- # Add new rows for each comment by copying the template row
18
- comments.each_with_index do |comment, _index|
19
- # Convert comment to symbol keys for consistent access
20
- comment_data = symbolize_keys(comment)
21
-
22
- # Copy the template row and insert it
23
- begin
24
- new_row = template_row.copy
25
- new_row.insert_before(template_row)
26
- row = new_row
27
- rescue StandardError => e
28
- puts "Warning: Could not add row for comment #{comment_data[:id]}: #{e.message}"
29
- next
30
- end
21
+ # The template row (first row in the table) is copied for each comment;
22
+ # all original template rows are removed afterwards via their XML
23
+ # nodes (the shipped 2012-03 template carries extra blank rows beyond
24
+ # the first, and the docx gem's Row exposes no #remove).
25
+ template_rows = table.rows.to_a
26
+ comments.each { |comment| fill_row(template_rows.first, comment, options) }
27
+ template_rows.each { |row| row.node.remove }
28
+
29
+ doc.save(output_path)
30
+ write_header_metadata(output_path, options)
31
+ output_path
32
+ end
33
+
34
+ private
35
+
36
+ def fill_row(template_row, comment, options)
37
+ comment = Comment.from_hash(comment) unless comment.is_a?(Comment)
38
+
39
+ new_row = template_row.copy
40
+ new_row.insert_before(template_row)
41
+
42
+ set_cell_text(new_row.cells[0], comment.id.to_s)
43
+ set_cell_text(new_row.cells[1], comment.line_number.to_s)
44
+ set_cell_text(new_row.cells[2], comment.clause.to_s)
45
+ set_cell_text(new_row.cells[3], comment.element.to_s)
46
+ set_cell_text(new_row.cells[4], comment.type.to_s)
47
+ set_cell_text(new_row.cells[5], comment.comments.to_s)
48
+ set_cell_text(new_row.cells[6], comment.proposed_change.to_s)
49
+
50
+ observations = comment.observations.to_s
51
+ return if observations.empty?
52
+
53
+ set_cell_text(new_row.cells[7], observations)
54
+ apply_shading(new_row.cells[7], observations) if options[:shading]
55
+ rescue StandardError => e
56
+ puts "Warning: Could not add row for comment #{comment.id}: #{e.message}"
57
+ end
58
+
59
+ def write_header_metadata(output_path, options)
60
+ metadata = HEADER_METADATA.map do |key, label|
61
+ value = options[key].to_s.strip
62
+ value.empty? ? nil : [label, value]
63
+ end.compact
64
+ return if metadata.empty?
31
65
 
32
- # Map comment to table cells using text substitution
33
- set_cell_text(row.cells[0], comment_data[:id] || "")
34
- set_cell_text(row.cells[1], comment_data.dig(:locality, :line_number) || "")
35
- set_cell_text(row.cells[2], comment_data.dig(:locality, :clause) || "")
36
- set_cell_text(row.cells[3], comment_data.dig(:locality, :element) || "")
37
- set_cell_text(row.cells[4], comment_data[:type] || "")
38
- set_cell_text(row.cells[5], comment_data[:comments] || "")
39
- set_cell_text(row.cells[6], comment_data[:proposed_change] || "")
40
-
41
- # Handle observations with optional shading
42
- observations = comment_data[:observations]
43
- if observations && !observations.empty?
44
- set_cell_text(row.cells[7], observations)
45
- apply_shading(row.cells[7], observations) if options[:shading]
66
+ rewrite_headers(output_path, metadata)
67
+ end
68
+
69
+ def rewrite_headers(output_path, metadata)
70
+ # In-memory buffer + File.binwrite rather than rewriting the archive
71
+ # in place: rubyzip commits in-place edits with File.rename, which
72
+ # fails with EACCES on Windows.
73
+ buffer = Zip::OutputStream.write_buffer do |out|
74
+ Zip::File.open(output_path) do |zip|
75
+ zip.entries.each do |entry|
76
+ # Block form: the entry stream is closed eagerly, otherwise its
77
+ # handle keeps the file locked on Windows and the binwrite below
78
+ # fails with EACCES.
79
+ content = entry.get_input_stream(&:read)
80
+ out.put_next_entry(entry.name)
81
+ out.write(patch_header(entry.name, content, metadata))
82
+ end
46
83
  end
47
84
  end
85
+ File.binwrite(output_path, buffer.string)
86
+ end
48
87
 
49
- # Remove the original template row after all comments are added
50
- template_row.remove if template_row.respond_to?(:remove)
88
+ def patch_header(entry_name, content, metadata)
89
+ return content unless entry_name.match?(%r{\Aword/header\d*\.xml\z})
51
90
 
52
- doc.save(output_path)
91
+ xml = content.dup.force_encoding(Encoding::UTF_8)
92
+ metadata.each do |label, value|
93
+ xml.sub!(%r{(<w:t[^>]*>)\s*#{label}:\s*(</w:t>)}) do
94
+ "#{Regexp.last_match(1)}#{label}: #{escape(value)}#{Regexp.last_match(2)}"
95
+ end
96
+ end
97
+ xml
53
98
  end
54
99
 
55
- private
100
+ def escape(text)
101
+ text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
102
+ end
56
103
 
57
104
  def set_cell_text(cell, text)
58
105
  return if text.nil? || text.empty?
@@ -86,14 +133,16 @@ module Commenter
86
133
  puts "Warning: Could not set text '#{text}' in cell: #{e.message}"
87
134
  end
88
135
 
89
- def symbolize_keys(hash)
90
- return hash unless hash.is_a?(Hash)
136
+ SHADING_COLORS = {
137
+ accept_with_modifications: "C4D79B", # Olive Green
138
+ accepted: "92D050", # Green
139
+ noted: "8DB4E2", # Blue
140
+ rejected: "FF99CC", # Pink
141
+ todo: "D9D9D9" # Light Gray (for diagonal stripes, we use solid for now)
142
+ }.freeze
91
143
 
92
- hash.each_with_object({}) do |(key, value), result|
93
- new_key = key.to_sym
94
- new_value = value.is_a?(Hash) ? symbolize_keys(value) : value
95
- result[new_key] = new_value
96
- end
144
+ def determine_shading_color(observation)
145
+ SHADING_COLORS[DispositionStatus.match(observation)]
97
146
  end
98
147
 
99
148
  def apply_shading(cell, observation)
@@ -111,23 +160,6 @@ module Commenter
111
160
  puts "Warning: Could not apply shading to cell: #{e.message}"
112
161
  end
113
162
 
114
- def determine_shading_color(observation)
115
- text = observation.downcase.strip
116
-
117
- case text
118
- when /awm|accept with modifications/
119
- "C4D79B" # Olive Green
120
- when /accept(ed)?/
121
- "92D050" # Green
122
- when /noted/
123
- "8DB4E2" # Blue
124
- when /reject(ed)?/
125
- "FF99CC" # Pink
126
- when /todo/
127
- "D9D9D9" # Light Gray (for diagonal stripes, we'll use solid for now)
128
- end
129
- end
130
-
131
163
  def apply_cell_shading(cell, color)
132
164
  # Access the cell's XML node
133
165
  cell_node = cell.node
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Commenter
6
+ # GitHub issue tracking state attached to a comment by github-create /
7
+ # github-retrieve.
8
+ class GithubInfo < Lutaml::Model::Serializable
9
+ attribute :issue_number, :integer
10
+ attribute :issue_url, :string
11
+ attribute :status, :string
12
+ attribute :created_at, :string
13
+ attribute :updated_at, :string
14
+
15
+ yaml do
16
+ map "issue_number", to: :issue_number
17
+ map "issue_url", to: :issue_url
18
+ map "status", to: :status
19
+ map "created_at", to: :created_at
20
+ map "updated_at", to: :updated_at
21
+ end
22
+ end
23
+ end
@@ -19,8 +19,7 @@ module Commenter
19
19
  end
20
20
 
21
21
  def create_issues_from_yaml(yaml_file, options = {})
22
- data = YAML.load_file(yaml_file)
23
- comment_sheet = CommentSheet.from_hash(data)
22
+ comment_sheet = CommentSheet.from_yaml(File.read(yaml_file))
24
23
 
25
24
  # Override stage if provided
26
25
  comment_sheet.stage = options[:stage] if options[:stage]
@@ -282,11 +281,8 @@ module Commenter
282
281
  comment = comment_sheet.comments.find { |c| c.id == result[:comment_id] }
283
282
  next unless comment
284
283
 
285
- # Add GitHub information to the comment
286
- comment.github[:issue_number] = result[:issue_number]
287
- comment.github[:issue_url] = result[:issue_url]
288
- comment.github[:status] = "open"
289
- comment.github[:created_at] = Time.now.utc.iso8601
284
+ comment.record_github_issue(issue_number: result[:issue_number], issue_url: result[:issue_url],
285
+ status: "open", created_at: Time.now.utc.iso8601)
290
286
  end
291
287
 
292
288
  # Write updated YAML
@@ -304,8 +300,7 @@ module Commenter
304
300
  end
305
301
 
306
302
  def retrieve_observations_from_yaml(yaml_file, options = {})
307
- data = YAML.load_file(yaml_file)
308
- comment_sheet = CommentSheet.from_hash(data)
303
+ comment_sheet = CommentSheet.from_yaml(File.read(yaml_file))
309
304
 
310
305
  results = []
311
306
  comment_sheet.comments.each do |comment|
@@ -349,8 +344,8 @@ module Commenter
349
344
  if observation
350
345
  # Update comment with observation and current status
351
346
  comment.observations = observation
352
- comment.github[:status] = issue.state
353
- comment.github[:updated_at] = Time.now.utc.iso8601
347
+ comment.github.status = issue.state
348
+ comment.github.updated_at = Time.now.utc.iso8601
354
349
 
355
350
  {
356
351
  comment_id: comment.id,
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "roo"
4
- require_relative "../comment_sheet"
5
- require_relative "../comment"
6
4
 
7
5
  module Commenter
8
6
  class Parser
@@ -201,7 +199,7 @@ module Commenter
201
199
  clause: clause.empty? ? nil : clause,
202
200
  element: clause_title.empty? ? nil : clause_title
203
201
  }.compact,
204
- type: CommentType.code(cell_value_str(row, col_map[:comment_type]).to_s),
202
+ type: CommentType.full_name(cell_value_str(row, col_map[:comment_type])),
205
203
  comments: comment_text.to_s.strip,
206
204
  proposed_change: cell_value_str(row, col_map[:proposed_change]) ||
207
205
  cell_value_str(row, col_map[:proposal_on_text])
@@ -2,8 +2,6 @@
2
2
 
3
3
  require "zip"
4
4
  require "nokogiri"
5
- require_relative "../comment"
6
- require_relative "../comment_sheet"
7
5
 
8
6
  module Commenter
9
7
  class Parser
@@ -54,7 +52,7 @@ module Commenter
54
52
  raise Commenter::Error, "word/document.xml not found in #{path}" unless entry
55
53
 
56
54
  remarks = read_remarks(zip)
57
- changes = stream_changes(entry.get_input_stream, anchors)
55
+ changes = entry.get_input_stream { |stream| stream_changes(stream, anchors) }
58
56
  end
59
57
 
60
58
  CommentSheet.new(
@@ -80,7 +78,7 @@ module Commenter
80
78
  entry = zip.glob("word/comments.xml").first
81
79
  return {} unless entry
82
80
 
83
- document = Nokogiri::XML(entry.get_input_stream)
81
+ document = entry.get_input_stream { |stream| Nokogiri::XML(stream) }
84
82
  document.xpath("//w:comment", "w" => W_NS).each_with_object({}) do |node, remarks|
85
83
  remarks[node["w:id"]] = {
86
84
  author: node["w:author"],
@@ -102,7 +100,7 @@ module Commenter
102
100
  id: format("%<body>s-%03<index>d", body: body, index: index + 1),
103
101
  body: body,
104
102
  locality: { clause: change[:clause], line_number: nil, element: change[:element] },
105
- type: options[:type] || "te",
103
+ type: CommentType.full_name(options[:type] || "te"),
106
104
  comments: "Track change (#{KIND_COMMENT.fetch(kind, kind)})",
107
105
  proposed_change: "#{KIND_LABEL.fetch(kind, kind)}: \"#{change[:text].strip}\"",
108
106
  observations: observations_for(options)
@@ -2,13 +2,12 @@
2
2
 
3
3
  require "docx"
4
4
  require "pathname"
5
- require_relative "comment_sheet"
6
- require_relative "comment"
7
- require_relative "parser/osd_xlsx_parser"
8
- require_relative "parser/track_change_docx_parser"
9
5
 
10
6
  module Commenter
11
7
  class Parser
8
+ autoload :OsdXlsxParser, "commenter/parser/osd_xlsx_parser"
9
+ autoload :TrackChangeDocxParser, "commenter/parser/track_change_docx_parser"
10
+
12
11
  def parse(input_path, options = {})
13
12
  format = detect_format(input_path, options)
14
13
 
@@ -90,7 +89,7 @@ module Commenter
90
89
  clause: presence(cells[2]),
91
90
  element: presence(cells[3])
92
91
  },
93
- type: CommentType.code(cells[4]),
92
+ type: CommentType.full_name(cells[4]),
94
93
  comments: cells[5] || "",
95
94
  proposed_change: presence(cells[6]),
96
95
  user_name: cells[0].to_s.strip
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Commenter
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/commenter.rb CHANGED
@@ -1,11 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "commenter/version"
4
- require_relative "commenter/comment"
5
- require_relative "commenter/comment_sheet"
6
- require_relative "commenter/parser"
7
- require_relative "commenter/filler"
8
- require_relative "commenter/github_integration"
9
4
 
10
5
  # Commenter converts ISO comment sheets (DOCX/XLSX) to structured YAML and
11
6
  # syncs comments to GitHub issues.
@@ -13,5 +8,16 @@ module Commenter
13
8
  class Error < StandardError; end
14
9
 
15
10
  autoload :CommentType, "commenter/comment_type"
11
+ autoload :CommentLocality, "commenter/comment_locality"
12
+ autoload :GithubInfo, "commenter/github_info"
13
+ autoload :DispositionStatus, "commenter/disposition_status"
14
+ autoload :Ballot, "commenter/ballot"
15
+ autoload :BallotReport, "commenter/ballot_report"
16
+ autoload :Comment, "commenter/comment"
17
+ autoload :CommentSheet, "commenter/comment_sheet"
16
18
  autoload :GitHubSession, "commenter/github_session"
19
+ autoload :Parser, "commenter/parser"
20
+ autoload :Filler, "commenter/filler"
21
+ autoload :GitHubIssueCreator, "commenter/github_integration"
22
+ autoload :GitHubIssueRetriever, "commenter/github_integration"
17
23
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Commenter::BallotReport do
6
+ def comment(body:, observations: nil, resolution_status: nil, github: {}, type: "technical")
7
+ Commenter::Comment.new(
8
+ id: "#{body}-1", body: body, type: type,
9
+ comments: "A comment", observations: observations,
10
+ resolution_status: resolution_status, github: github
11
+ )
12
+ end
13
+
14
+ let(:sheet) do
15
+ Commenter::CommentSheet.new(comments: [
16
+ comment(body: "DE", observations: "Accepted."),
17
+ comment(body: "DE", observations: "Accept with modifications"),
18
+ comment(body: "US", observations: "Noted"),
19
+ comment(body: "US", observations: "Rejected"),
20
+ comment(body: "US"),
21
+ comment(body: "CS", resolution_status: "Not accepted"),
22
+ comment(body: "CS", github: { status: "open" })
23
+ ])
24
+ end
25
+
26
+ describe ".status_for" do
27
+ it "prefers observation text, then resolution status, then GitHub state" do
28
+ comments = sheet.comments
29
+ expect(described_class.status_for(comments[0])).to eq(:accepted)
30
+ expect(described_class.status_for(comments[4])).to eq(:undecided)
31
+ expect(described_class.status_for(comments[5])).to eq(:rejected)
32
+ expect(described_class.status_for(comments[6])).to eq(:open)
33
+ end
34
+ end
35
+
36
+ describe ".counts" do
37
+ it "counts per member body, disposition, and type" do
38
+ counts = described_class.counts(sheet)
39
+
40
+ expect(counts[:total]).to eq(7)
41
+ expect(counts[:bodies]["DE"][:total]).to eq(2)
42
+ expect(counts[:bodies]["DE"][:accepted]).to eq(1)
43
+ expect(counts[:bodies]["DE"][:accept_with_modifications]).to eq(1)
44
+ expect(counts[:bodies]["US"][:noted]).to eq(1)
45
+ expect(counts[:bodies]["US"][:rejected]).to eq(1)
46
+ expect(counts[:bodies]["US"][:undecided]).to eq(1)
47
+ expect(counts[:bodies]["CS"][:open]).to eq(1)
48
+ expect(counts[:types]["technical"]).to eq(7)
49
+ end
50
+ end
51
+
52
+ describe ".to_markdown" do
53
+ it "renders a ballot report table with totals" do
54
+ markdown = described_class.to_markdown(sheet)
55
+
56
+ expect(markdown).to start_with("| Body | Total | Accepted | AWM | Noted | Rejected | TODO | Open | Undecided |")
57
+ expect(markdown).to include("| DE | 2 | 1 | 1 | 0 | 0 | 0 | 0 | 0 |")
58
+ expect(markdown).to include("| **Total** | **7** | **1** | **1** | **1** | **2** | **0** | **1** | **1** |")
59
+ expect(markdown).to include("Comment types: technical 7")
60
+ end
61
+ end
62
+ end