Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lib/submissions/generate_result_attachments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -462,10 +462,22 @@ def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is
if field['type'].in?(%w[multiple radio])
option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] }

# Stale option_uuid (template edited after submission) would raise on option['value'].
# Skip so the rest of the PDF still generates; log so missing marks stay visible.
if option.nil?
Rails.logger.warn(
"Skipping option area with unknown option_uuid (submitter=#{submitter.id}, " \
"field=#{field['uuid']}, option_uuid=#{area['option_uuid']})"
)
next
end

option_name = option['value'].presence
option_name ||= "#{I18n.t('option', locale: locale)} #{field['options'].index(option) + 1}"

value = Array.wrap(value).include?(option_name)
else
value = Submitters::NormalizeValues::TRUE_VALUES.include?(value)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reusing NormalizeValues::TRUE_VALUES is the right call here, it's the exact table normalize_value uses for checkboxes so the PDF and the normalized data agree on what "checked" means. 👍🏽

Small thought, not a blocker: any reason not to call Submitters::NormalizeValues.normalize_value(field, value) == true instead of reaching for the constant? Keeps the truth table behind one door if it ever grows. Totally your call, the constant reads fine as-is.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — keeping the cast on the PDF path for this PR. Write-time normalization for checkbox defaults (and the related condition / nil-guard gaps) is a good follow-up so we don’t expand scope here.

end

next unless value == true
Expand Down
265 changes: 256 additions & 9 deletions spec/lib/submissions/generate_result_attachments_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,22 @@
{ attachment_uuid => doc }
end

# Point the submitter's submission at a single text field whose one area is
# `area`, so `fill_submitter_fields` reaches the page lookup for that area.
def assign_field_area(area)
submitter.submission.update!(
template_fields: [
{ 'uuid' => SecureRandom.uuid, 'submitter_uuid' => submitter.uuid,
'type' => 'text', 'areas' => [area] }
]
)
def base_area(overrides = {})
{
'x' => 0.1,
'y' => 0.1,
'w' => 0.05,
'h' => 0.05,
'attachment_uuid' => attachment_uuid,
'page' => 0
}.merge(overrides)
end

# Point the submitter's submission at fields so fill_submitter_fields reaches
# the page lookup and drawing branches under test.
def assign_fields(fields, values = {})
submitter.submission.update!(template_fields: fields)
submitter.update!(values: values)
end

def fill
Expand All @@ -36,9 +43,34 @@ def fill
)
end

def image_xobject_count
page = pdfs_index[attachment_uuid].pages[0]
xobjects = page.resources[:XObject]
return 0 if xobjects.nil?

count = 0
# HexaPDF::Dictionary supports each but not each_value
xobjects.each { |_name, obj| count += 1 if obj[:Subtype] == :Image } # rubocop:disable Style/HashEachMethods
count
end

def page_content_has_text?
page = pdfs_index[attachment_uuid].pages[0]
content = page.contents
content = content.data if content.respond_to?(:data)
content.to_s.match?(/Tj|TJ/)
end

before { allow(Rails.logger).to receive(:warn) }

describe '.fill_submitter_fields with a missing area page' do
def assign_field_area(area)
assign_fields(
[{ 'uuid' => SecureRandom.uuid, 'submitter_uuid' => submitter.uuid,
'type' => 'text', 'areas' => [area] }]
)
end

context 'when the area omits the page key' do
before { assign_field_area('attachment_uuid' => attachment_uuid) }

Expand Down Expand Up @@ -78,4 +110,219 @@ def fill
end
end
end

describe '.fill_submitter_fields selection rendering' do
let(:field_uuid) { SecureRandom.uuid }

context 'when the field is a checkbox' do
def assign_checkbox(value)
assign_fields(
[{ 'uuid' => field_uuid, 'submitter_uuid' => submitter.uuid,
'type' => 'checkbox', 'areas' => [base_area] }],
{ field_uuid => value }
)
end

it 'draws a check when value is boolean true' do
assign_checkbox(true)
fill
expect(image_xobject_count).to eq(1)
end

it 'draws a check when value is the string "true" (prefill/API truthiness)' do
assign_checkbox('true')
fill
expect(image_xobject_count).to eq(1)
end

it 'draws a check for other truthy string values' do
%w[1 yes].each do |value|
doc = HexaPDF::Document.new
doc.pages.add
pdfs_index[attachment_uuid] = doc

assign_checkbox(value)
fill
expect(image_xobject_count).to eq(1), "expected check for #{value.inspect}"
end
end

it 'does not draw a check when value is false, "false", or nil' do
[false, 'false', nil].each do |value|
doc = HexaPDF::Document.new
doc.pages.add
pdfs_index[attachment_uuid] = doc

assign_checkbox(value)
fill
expect(image_xobject_count).to eq(0), "expected no check for #{value.inspect}"
end
end
end

context 'when the field is a radio with option areas' do
it 'draws a check only on the matching option area' do
yes_uuid = SecureRandom.uuid
no_uuid = SecureRandom.uuid
options = [
{ 'uuid' => yes_uuid, 'value' => 'Yes' },
{ 'uuid' => no_uuid, 'value' => 'No' }
]

assign_fields(
[{
'uuid' => field_uuid,
'submitter_uuid' => submitter.uuid,
'type' => 'radio',
'options' => options,
'areas' => [
base_area('option_uuid' => yes_uuid, 'x' => 0.1),
base_area('option_uuid' => no_uuid, 'x' => 0.3)
]
}],
{ field_uuid => 'Yes' }
)

fill
expect(image_xobject_count).to eq(1)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More of a question here, and not a blocker: the example says "only on the matching option area", but the assertion is just "the page has one image XObject", which would pass just as happily if we drew the check on the No area instead of Yes.

The two areas are at x 0.1 and 0.3, so the placement math should come out different. Is there a cheap way to assert the check landed in the right spot? The thing most likely to regress in this branch feels like a check on the wrong option, and a count wouldn't catch that one.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. Kept the “one check when both areas exist” case, then added a second step: value is Yes but only the No area is present → expect zero checks, so a wrong-option draw would fail.


# Selected Yes, but only the No area is on the page — must not draw a check.
doc = HexaPDF::Document.new
doc.pages.add
pdfs_index[attachment_uuid] = doc

assign_fields(
[{
'uuid' => field_uuid,
'submitter_uuid' => submitter.uuid,
'type' => 'radio',
'options' => options,
'areas' => [base_area('option_uuid' => no_uuid, 'x' => 0.3)]
}],
{ field_uuid => 'Yes' }
)

fill
expect(image_xobject_count).to eq(0)
end
end

context 'when the field is multiple with option areas' do
it 'draws a check on each selected option area' do
opt_a = SecureRandom.uuid
opt_b = SecureRandom.uuid
opt_c = SecureRandom.uuid

assign_fields(
[{
'uuid' => field_uuid,
'submitter_uuid' => submitter.uuid,
'type' => 'multiple',
'options' => [
{ 'uuid' => opt_a, 'value' => 'A' },
{ 'uuid' => opt_b, 'value' => 'B' },
{ 'uuid' => opt_c, 'value' => 'C' }
],
'areas' => [
base_area('option_uuid' => opt_a, 'x' => 0.1),
base_area('option_uuid' => opt_b, 'x' => 0.2),
base_area('option_uuid' => opt_c, 'x' => 0.3)
]
}],
{ field_uuid => %w[A B] }
)

fill
expect(image_xobject_count).to eq(2)
end
end

context 'when a radio option area has a stale option_uuid' do
let(:stale_option_uuid) { SecureRandom.uuid }

before do
assign_fields(
[{
'uuid' => field_uuid,
'submitter_uuid' => submitter.uuid,
'type' => 'radio',
'options' => [{ 'uuid' => SecureRandom.uuid, 'value' => 'Yes' }],
'areas' => [base_area('option_uuid' => stale_option_uuid)]
}],
{ field_uuid => 'Yes' }
)
end

it 'skips the area instead of raising' do
expect { fill }.not_to raise_error
expect(image_xobject_count).to eq(0)
end

it 'logs that the option area was skipped' do
fill
expect(Rails.logger).to have_received(:warn).with(
/Skipping option area with unknown option_uuid.*option_uuid=#{stale_option_uuid}/
)
end
end

context 'when radio/multiple has a single area without option_uuid' do
it 'draws selected radio value as text without raising' do
assign_fields(
[{
'uuid' => field_uuid,
'submitter_uuid' => submitter.uuid,
'type' => 'radio',
'options' => [
{ 'uuid' => SecureRandom.uuid, 'value' => 'Yes' },
{ 'uuid' => SecureRandom.uuid, 'value' => 'No' }
],
'areas' => [base_area('w' => 0.3, 'h' => 0.04)]
}],
{ field_uuid => 'Yes' }
)

expect { fill }.not_to raise_error
expect(image_xobject_count).to eq(0)
expect(page_content_has_text?).to be true
end

it 'draws selected multiple values as joined text without raising' do
assign_fields(
[{
'uuid' => field_uuid,
'submitter_uuid' => submitter.uuid,
'type' => 'multiple',
'options' => [
{ 'uuid' => SecureRandom.uuid, 'value' => 'A' },
{ 'uuid' => SecureRandom.uuid, 'value' => 'B' }
],
'areas' => [base_area('w' => 0.4, 'h' => 0.04)]
}],
{ field_uuid => %w[A B] }
)

expect { fill }.not_to raise_error
expect(image_xobject_count).to eq(0)
expect(page_content_has_text?).to be true
end
end

context 'when the field is text (regression)' do
it 'still renders typed text' do
assign_fields(
[{
'uuid' => field_uuid,
'submitter_uuid' => submitter.uuid,
'type' => 'text',
'areas' => [base_area('w' => 0.4, 'h' => 0.04)]
}],
{ field_uuid => 'Hello world' }
)

expect { fill }.not_to raise_error
expect(page_content_has_text?).to be true
end
end
end
end
Loading