diff --git a/app/uat_actions/uat_actions/generate_fluidx_barcodes.rb b/app/uat_actions/uat_actions/generate_fluidx_barcodes.rb new file mode 100644 index 0000000000..df895c7ab0 --- /dev/null +++ b/app/uat_actions/uat_actions/generate_fluidx_barcodes.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +# UAT action to generate randomised FluidX barcodes in the following format: +# : Ten characters in total +# : two uppercase letters +# : six random digits; may be truncated from the end to fit the length +# : +# : one digit that separates random part and index, i.e. '0' +# : 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] 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] an array of barcodes + # @return [Array] 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] 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 diff --git a/spec/uat_actions/generate_fluidx_barcodes_spec.rb b/spec/uat_actions/generate_fluidx_barcodes_spec.rb new file mode 100644 index 0000000000..c3062dd78b --- /dev/null +++ b/spec/uat_actions/generate_fluidx_barcodes_spec.rb @@ -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