Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Y24-327 [Sequencescape] Generate FluidX barcodes UAT action #4400

Merged
merged 2 commits into from
Oct 8, 2024
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
153 changes: 153 additions & 0 deletions app/uat_actions/uat_actions/generate_fluidx_barcodes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# frozen_string_literal: true

# UAT action to generate randomised FluidX barcodes in the following format:
# <prefix><random><suffix>: Ten characters in total
# <prefix>: two uppercase letters
# <random>: six random digits; may be truncated from the end to fit the length
# <suffix>: <zero><index>
# <zero>: one digit that separates random part and index, i.e. '0'
# <index>: sequential tail, 1 to 3 digits, e.g., '9', '99', and '999'
#
# Sequential tail in barcodes may be higher and may have gaps because of
# handling duplicates.
# Random part may be truncated from right to fit the length of the barcode.
class UatActions::GenerateFluidxBarcodes < UatActions
class_attribute :max_number_of_iterations
self.title = 'Generate FluidX Barcodes'
self.description = 'Generate randomised FluidX barcodes'
self.category = :auxiliary_data
self.max_number_of_iterations = 10

validates :barcode_count,
presence: true,
numericality: {
only_integer: true,
greater_than: 0,
less_than_or_equal_to: 96
}
validates :barcode_prefix,
presence: true,
length: {
is: 2
},
format: {
with: /\A[A-Z]{2}\z/,
message: 'only allows two uppercase letters'
}
validates :barcode_index,
presence: true,
numericality: {
only_integer: true,
greater_than: 0,
less_than_or_equal_to: 900
}

form_field :barcode_count,
:number_field,
label: 'Number of barcodes',
help: 'The number of FluidX barcodes that should be generated',
options: {
min: 1,
max: 96,
value: 1
}
form_field :barcode_prefix,
:text_field,
label: 'Barcode prefix',
help: 'The prefix to be used for the barcodes',
options: {
maxlength: 2,
oninput: 'javascript:this.value=this.value.toUpperCase().replace(/[^A-Z]/g, "")',
value: 'TS'
}
form_field :barcode_index,
:number_field,
label: 'Barcode index',
help: 'The starting index to make a sequential tail for the barcodes',
options: {
min: 1,
max: 900,
value: 1
}

# This method is called from the save method after validations have passed.
# If the return value is true, the report hash populated by the action is
# used for rendering the response. If the return value is false, the errors
# collection is used.
#
# @return [Boolean] true if the action was successful; false otherwise
def perform
random = generate_random
barcodes = generate_barcodes(barcode_count.to_i, barcode_prefix, random, barcode_index.to_i)
if barcodes.size == barcode_count.to_i
report['barcodes'] = barcodes
true
else
errors.add(:base, 'Failed to generate unique barcodes')
false
end
end

# Returns a default copy of the UatAction which will be used to fill in the form.
#
# @return [UatActions::GenerateFluidxBarcodes] a default object for rendering a form
def self.default
new(barcode_count: '1', barcode_prefix: 'TS', barcode_index: '1')
end

private

# Generates a random string of six digits. Each digit is randomised separately
# for uniform distribution of digits because the string may be truncated later
# to fit the barcode_index.
#
# @return [String] a random string of six digits
def generate_random
Array.new(6) { rand(0..9) }.join # uniform distribution of digits
end

# Generates an array of barcodes with the specified count, prefix, random
# part, and starting index for the sequential tail. It attempts to generate
# unique barcodes, iterating up to max_number_of_iterations before giving up.
#
# @param count [Integer] the number of barcodes to generate
# @param prefix [String] the prefix to be used for the barcodes
# @param random [String] random part for the barcodes
# @param index [Integer] the starting index for the sequential tail
# @return [Array<String>] an array of unique barcodes
def generate_barcodes(count, prefix, random, index)
barcodes = []
max_number_of_iterations.times do
# Filter out the barcodes that already exist in the database and make sure
# there is no duplication in the generated barcodes.
barcodes.concat(filter_barcodes(build_barcodes(count, prefix, random, index))).uniq!
return barcodes if barcodes.size == barcode_count.to_i
count = barcode_count.to_i - barcodes.size # More to generate.
index += barcode_count.to_i # Continue index.
end
end

# Filters out the barcodes that already exist in the database.
#
# @param barcodes [Array<String>] an array of barcodes
# @return [Array<String>] an array of unique barcodes
def filter_barcodes(barcodes)
barcodes - Barcode.where(barcode: barcodes).pluck(:barcode)
end

# Builds an array of barcodes with the specified count, prefix, random
# part, and starting index for the sequential tail.
#
# @param count [Integer] the number of barcodes to generate
# @param prefix [String] the prefix to be used for the barcodes
# @param random [String] random part for the barcodes
# @param index [Integer] the starting index for the suffix
# @return [Array<String>] an array of barcodes
def build_barcodes(count, prefix, random, index)
(index...(index + count)).map do |counter|
suffix = "0#{counter}"
random = random[0, 8 - suffix.length] # 8 FluidX digits minus suffix
"#{prefix}#{random}#{suffix}"
end
end
end
153 changes: 153 additions & 0 deletions spec/uat_actions/generate_fluidx_barcodes_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# frozen_string_literal: true

require 'rails_helper'

# rubocop:disable RSpec/MultipleExpectations
describe UatActions::GenerateFluidxBarcodes do
let(:uat_action) { described_class.new(params) }

describe '.default' do
let(:uat_action) { described_class.default }

it 'returns a default instance' do
expect(uat_action).to be_an_instance_of(described_class)
expect(uat_action).to be_valid
end
end

describe '#perform' do
context 'with default instance' do
let(:uat_action) { described_class.default }

it 'generates barcodes' do
expect(uat_action.perform).to be true # Note that this calls the perform method
expect(uat_action.report['barcodes'].size).to eq uat_action.barcode_count.to_i
expect(uat_action.report['barcodes'].first).to match(/\A[A-Z]{2}\d{8}\z/)
expect(uat_action.report['barcodes'].first).to start_with(uat_action.barcode_prefix)
expect(uat_action.report['barcodes'].first).to end_with(uat_action.barcode_index.to_s)
end
end

context 'with valid options' do
let(:params) { { barcode_count: 10, barcode_prefix: 'SQ', barcode_index: 1 } }

# rubocop:disable RSpec/ExampleLength
it 'generates barcodes' do
expect(uat_action.perform).to be true
expect(uat_action.report['barcodes'].size).to eq params[:barcode_count].to_i
expect(uat_action.report['barcodes']).to all(match(/\A[A-Z]{2}\d{8}\z/))
expect(uat_action.report['barcodes']).to all(start_with(params[:barcode_prefix]))
expect(uat_action.report['barcodes'].first).to end_with(params[:barcode_index].to_s) # first index
expect(uat_action.report['barcodes'].last).to end_with(params[:barcode_count].to_s) # last
end
# rubocop:enable RSpec/ExampleLength
end

context 'with existing barcodes' do
let(:params) { { barcode_count: 10, barcode_prefix: 'SQ', barcode_index: 1 } }

let(:existing_barcodes) do
# Create 10 FluidX barcodes: SQ00000001 to SQ00000010
(1..10).map { |index| create(:fluidx, barcode: format('SQ%08d', index)).barcode }
end

before do
existing_barcodes
# Make generated barcodes predictable.
allow(uat_action).to receive(:generate_random).and_return('000000')
end

it 'generates unique barcodes' do
expect(uat_action.perform).to be true
existing_barcodes.each { |existing| expect(uat_action.report['barcodes']).not_to include(existing) }
end
end

context 'with max number of iterations' do
let(:params) { { barcode_count: 10, barcode_prefix: 'SQ', barcode_index: 1 } }

before { allow(uat_action).to receive(:filter_barcodes).and_return([]) }

it 'fails to generate unique barcodes' do
expect(uat_action.perform).to be false
expect(uat_action.errors[:base]).to include('Failed to generate unique barcodes')
end
end
end

describe '#valid?' do
shared_examples 'an invalid action' do |field, error_message|
it "is invalid when #{field} is invalid" do
expect(uat_action).not_to be_valid
expect(uat_action.errors[field]).to include(error_message)
end
end

context 'with barcode_count not present' do
let(:params) { { barcode_prefix: 'SQ', barcode_index: 1 } }

it_behaves_like 'an invalid action', :barcode_count, 'can\'t be blank'
end

context 'with barcode_count non-integer' do
let(:params) { { barcode_count: 'not-an-integer', barcode_prefix: 'SQ', barcode_index: 1 } }

it_behaves_like 'an invalid action', :barcode_count, 'is not a number'
end

context 'with barcode_count smaller than one' do
let(:params) { { barcode_count: '0', barcode_prefix: 'SQ', barcode_index: 1 } }

it_behaves_like 'an invalid action', :barcode_count, 'must be greater than 0'
end

context 'with barcode_count greater than 96' do
let(:params) { { barcode_count: '97', barcode_prefix: 'SQ', barcode_index: 1 } }

it_behaves_like 'an invalid action', :barcode_count, 'must be less than or equal to 96'
end

context 'with barcode_prefix not present' do
let(:params) { { barcode_count: '96', barcode_index: 1 } }

it_behaves_like 'an invalid action', :barcode_prefix, 'can\'t be blank'
end

context 'with barcode_prefix longer than two characters' do
let(:params) { { barcode_count: '10', barcode_prefix: 'ABC', barcode_index: 1 } }

it_behaves_like 'an invalid action', :barcode_prefix, 'is the wrong length (should be 2 characters)'
end

context 'with barcode_prefix not uppercase' do
let(:params) { { barcode_count: '10', barcode_prefix: 'bc', barcode_index: 1 } }

it_behaves_like 'an invalid action', :barcode_prefix, 'only allows two uppercase letters'
end

context 'with barcode_index non present' do
let(:params) { { barcode_count: '10', barcode_prefix: 'SQ' } }

it_behaves_like 'an invalid action', :barcode_index, 'can\'t be blank'
end

context 'with barcode_index non-integer' do
let(:params) { { barcode_count: '10', barcode_prefix: 'SQ', barcode_index: 'not-an-integer' } }

it_behaves_like 'an invalid action', :barcode_index, 'is not a number'
end

context 'with barcode_index smaller than one' do
let(:params) { { barcode_count: '10', barcode_prefix: 'SQ', barcode_index: 0 } }

it_behaves_like 'an invalid action', :barcode_index, 'must be greater than 0'
end

context 'with barcode_count less than 901' do
let(:params) { { barcode_count: '10', barcode_prefix: 'SQ', barcode_index: 901 } }

it_behaves_like 'an invalid action', :barcode_index, 'must be less than or equal to 900'
end
end
end
# rubocop:enable RSpec/MultipleExpectations
Loading