Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
da933c8
added a version of tenstamp creator and refactored multistamp JS to h…
andrewsparkes May 11, 2026
3cee4a0
added tests for allWellsFromPlates function
andrewsparkes May 11, 2026
3c477b0
added test coverage for stock plate with no submission presenter
andrewsparkes May 11, 2026
72aa05f
added tests for validator
andrewsparkes May 12, 2026
9f2baad
added tests for transferFromAllWells function
andrewsparkes May 12, 2026
d4298e2
added tests for TenStampAllWells labware creator
andrewsparkes May 12, 2026
df9c5ba
removed config flag for default state as does nothing here
andrewsparkes May 12, 2026
556f0b7
Merge branch 'develop' into y25-623-scrna-aggregate-cite-sup-xp-and-i…
andrewsparkes May 12, 2026
df6e1b9
Modified to remove aggregation and enforce cherrypicking for XP and I…
andrewsparkes May 15, 2026
88318e0
combined XP and Input robots into one as mixture of plates accepted
andrewsparkes May 19, 2026
5cd156c
removed hardcoded state in presenter as state is now correctly determ…
andrewsparkes May 20, 2026
4447bd9
removed unneeded state test
andrewsparkes May 20, 2026
c31b3bb
modified error message to be less fatalistic
andrewsparkes May 20, 2026
35f4d75
removed unneeded test
andrewsparkes May 21, 2026
991a1ee
added validation to check whether source plate has more samples than …
andrewsparkes May 21, 2026
ff9aaf7
changed wording of error message
andrewsparkes Jun 4, 2026
f1f338d
removed unneeded request from hash
andrewsparkes Jun 4, 2026
e57f84a
Merge branch 'develop' into y25-623-scrna-aggregate-cite-sup-xp-and-i…
andrewsparkes Jun 8, 2026
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
32 changes: 30 additions & 2 deletions app/frontend/javascript/multi-stamp/components/MultiStamp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,20 @@ import {
checkDuplicates,
checkSize,
checkForUnacceptablePlatePurpose,
checkForPlateOverfull,
checkMinCountRequests,
} from '@/javascript/shared/components/plateScanValidators.js'
import devourApi from '@/javascript/shared/devourApi.js'
import buildPlateObjs from '@/javascript/shared/plateHelpers.js'
import { handleFailedRequest, requestIsActive, requestsFromPlates } from '@/javascript/shared/requestHelpers.js'
import {
allWellsFromPlates,
handleFailedRequest,
requestIsActive,
requestsFromPlates,
} from '@/javascript/shared/requestHelpers.js'
import resources from '@/javascript/shared/resources.js'
import { transferPlatesToPlatesCreator } from '@/javascript/shared/transfersCreators.js'
import { transfersFromRequests } from '@/javascript/shared/transfersLayouts.js'
import { transfersFromRequests, transfersFromAllWells } from '@/javascript/shared/transfersLayouts.js'
import MultiStampTransfers from './MultiStampTransfers.vue'
import NullFilter from './NullFilter.vue'
import PlateSummary from './PlateSummary.vue'
Expand Down Expand Up @@ -150,6 +156,11 @@ export default {
required: false,
default: 'false',
},

// Flag to transfer all wells with aliquots, regardless of whether they have requests.
// Defaults to false.
// Also referenced as transfer-all-wells and transfer_all_wells
transferAllWells: { type: String, required: false, default: 'false' },
},
data() {
return {
Expand Down Expand Up @@ -226,7 +237,13 @@ export default {
}
return requestsWithPlatesArray
},
allWellsWithAliquots() {
return allWellsFromPlates(this.validPlates)
},
transfers() {
if (this.transferAllWells === 'true') {
return transfersFromAllWells(this.allWellsWithAliquots, this.transfersLayout)
}
return transfersFromRequests(this.requestsWithPlatesFiltered, this.transfersLayout)
},
validTransfers() {
Expand Down Expand Up @@ -292,6 +309,17 @@ export default {
validators.push(checkMinCountRequests(1))
}

// This check is only applicable if we want to transfer all wells with aliquots from a source plate.
// We need to check that number of samples in the source plate is not higher than the number of remaining empty destination wells.
if (this.transferAllWells === 'true') {
validators.push(
checkForPlateOverfull(
this.targetRowsNumber * this.targetColumnsNumber,
(plate) => this.transfers.valid.filter((t) => t.plateObj.plate.uuid !== plate.uuid).length,
),
)
}

return validators
},
},
Expand Down
28 changes: 28 additions & 0 deletions app/frontend/javascript/shared/components/plateScanValidators.js
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,33 @@ const checkForUnacceptablePlatePurpose = (acceptable_purposes) => {
}
}

// Returns a validator that checks the number of wells with aliquots on the scanned plate
// does not exceed the number of remaining empty wells on the destination plate.
// Args:
// targetCapacity - Integer with the total number of wells on the destination plate
// getOtherTransferCount - A function that, given the plate being validated, returns the
// number of transfers already allocated from OTHER plates (not this
// one). Excluding the current plate's own transfers breaks the Vue
// reactive cycle: if the plate is in `validPlates` its transfers are
// excluded, so the result is stable regardless of whether the plate
// is currently considered valid or not.
// Returns:
// Validator handler that receives a plate and returns a validation message
const checkForPlateOverfull = (targetCapacity, getOtherTransferCount) => {
return (plate) => {
if (!plate) return { valid: false, message: 'Plate not found' }
const wellsWithAliquotsOnCurrentPlate = plate.wells.filter((well) => well.aliquots.length > 0).length
const remainingDestinationWells = targetCapacity - getOtherTransferCount(plate)
if (wellsWithAliquotsOnCurrentPlate <= remainingDestinationWells) {
return validScanMessage()
}
return {
valid: false,
message: `There are only ${remainingDestinationWells} destination wells remaining, but this plate has ${wellsWithAliquotsOnCurrentPlate} wells with samples.`,
}
}
}

export {
checkSize,
checkDuplicates,
Expand All @@ -396,4 +423,5 @@ export {
checkMinCountRequests,
checkAllSamplesInColumnsList,
checkForUnacceptablePlatePurpose,
checkForPlateOverfull,
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
checkState,
checkQCableWalkingBy,
checkForUnacceptablePlatePurpose,
checkForPlateOverfull,
} from '@/javascript/shared/components/plateScanValidators'

describe('checkSize', () => {
Expand Down Expand Up @@ -665,6 +666,61 @@ describe('checkPlateWithSameReadyLibrarySubmissions', () => {
})
})

describe('checkForPlateOverfull', () => {
const makeWell = (aliquotCount) => ({ aliquots: Array(aliquotCount).fill({}) })
const makeTransfer = (plateUuid) => ({ plateObj: { plate: { uuid: plateUuid } } })

it('fails when plate is null', () => {
expect(checkForPlateOverfull(96, () => 0)(null)).toEqual({
valid: false,
message: 'Plate not found',
})
})

it('passes when the number of wells with aliquots is less than the remaining destination wells', () => {
const plate = { uuid: 'plate-1', wells: [makeWell(1), makeWell(1), makeWell(0)] }
const getOtherTransferCount = () => 90
expect(checkForPlateOverfull(96, getOtherTransferCount)(plate)).toEqual({ valid: true })
})

it('passes when the number of wells with aliquots exactly equals the remaining destination wells', () => {
const plate = { uuid: 'plate-1', wells: [makeWell(1), makeWell(1), makeWell(1)] }
const getOtherTransferCount = () => 93
expect(checkForPlateOverfull(96, getOtherTransferCount)(plate)).toEqual({ valid: true })
})

it('fails when the number of wells with aliquots exceeds the remaining destination wells', () => {
const plate = { uuid: 'plate-1', wells: [makeWell(1), makeWell(1), makeWell(1)] }
const getOtherTransferCount = () => 94
expect(checkForPlateOverfull(96, getOtherTransferCount)(plate)).toEqual({
valid: false,
message: `There are only 2 destination wells remaining, but this plate has 3 wells with samples.`,
})
})

it('excludes the current plate own transfers so validation is stable when the plate is already in validPlates', () => {
// plate-1 has 96 wells and exactly fills the target. When it is already in validPlates its
// own 96 transfers must not be counted against itself, otherwise valid→invalid→valid looping occurs.
const plate = { uuid: 'plate-1', wells: Array(96).fill(makeWell(1)) }
const transfers = Array(96).fill(makeTransfer('plate-1'))
const getOtherTransferCount = (p) => transfers.filter((t) => t.plateObj.plate.uuid !== p.uuid).length
// plate-1's own transfers are excluded → otherCount = 0, remaining = 96, valid
expect(checkForPlateOverfull(96, getOtherTransferCount)(plate)).toEqual({ valid: true })
})

it('counts transfers from other plates correctly', () => {
const plate1 = { uuid: 'plate-1', wells: [makeWell(1), makeWell(1)] } // 2 wells
const plate2 = { uuid: 'plate-2', wells: [makeWell(1), makeWell(1), makeWell(1)] } // 3 wells
// plate-1 already has 2 transfers in the list; validating plate-2 sees 2 other transfers
const transfers = [makeTransfer('plate-1'), makeTransfer('plate-1')]
const getOtherTransferCount = (p) => transfers.filter((t) => t.plateObj.plate.uuid !== p.uuid).length
// remaining for plate-2 = 5 - 2 = 3, plate-2 has 3 wells → exactly fits
expect(checkForPlateOverfull(5, getOtherTransferCount)(plate2)).toEqual({ valid: true })
// remaining for plate-1 = 5 - 0 = 5, plate-1 has 2 wells → valid
expect(checkForPlateOverfull(5, getOtherTransferCount)(plate1)).toEqual({ valid: true })
})
})

describe('checkForUnacceptablePlatePurpose', () => {
const plate1 = {
purpose: { name: 'PurposeA' },
Expand Down
22 changes: 21 additions & 1 deletion app/frontend/javascript/shared/requestHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,26 @@ const requestsFromPlates = function (plateObjs) {
return requestsArray
}

// Gets all wells with aliquots from an array of plateObjs, regardless of whether they have requests.
// Returns one object per occupied well.
const allWellsFromPlates = function (plateObjs) {
const wellsArray = []
for (let p = 0; p < plateObjs.length; p++) {
const plateObj = plateObjs[p]
const wells = plateObj.plate.wells
for (let w = 0; w < wells.length; w++) {
const well = wells[w]
if (well.aliquots.length > 0) {
wellsArray.push({
well: well,
plateObj: plateObj,
})
}
}
}
return wellsArray
}

const handleFailedRequest = function (request) {
// generate an alert on the page
let title = 'Unexpected error'
Expand All @@ -50,4 +70,4 @@ const handleFailedRequest = function (request) {
})
}

export { handleFailedRequest, requestIsActive, requestIsLibraryCreation, requestsFromPlates }
export { allWellsFromPlates, handleFailedRequest, requestIsActive, requestIsLibraryCreation, requestsFromPlates }
57 changes: 56 additions & 1 deletion app/frontend/javascript/shared/requestHelpers.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,61 @@
import { handleFailedRequest } from '@/javascript/shared/requestHelpers.js'
import { allWellsFromPlates, handleFailedRequest } from '@/javascript/shared/requestHelpers.js'
import eventBus from '@/javascript/shared/eventBus.js'

describe('allWellsFromPlates', () => {
const buildPlateObj = (index, wells) => ({
index,
state: 'valid',
plate: { uuid: `plate-uuid-${index}`, wells },
})

const buildWell = (position, aliquotCount = 0) => ({
uuid: `well-uuid-${position}`,
position: { name: position },
aliquots: Array.from({ length: aliquotCount }, (_, i) => ({ uuid: `aliquot-${position}-${i}` })),
})

it('returns an empty array when given no plates', () => {
expect(allWellsFromPlates([])).toEqual([])
})

it('returns an empty array when all wells are empty', () => {
const plateObj = buildPlateObj(0, [buildWell('A1'), buildWell('B1')])
expect(allWellsFromPlates([plateObj])).toEqual([])
})

it('returns only wells that have aliquots', () => {
const emptyWell = buildWell('A1', 0)
const occupiedWell = buildWell('B1', 1)
const plateObj = buildPlateObj(0, [emptyWell, occupiedWell])

const result = allWellsFromPlates([plateObj])

expect(result).toHaveLength(1)
expect(result[0].well).toBe(occupiedWell)
})

it('includes the correct plateObj reference on each entry', () => {
const plateObj = buildPlateObj(0, [buildWell('A1', 1)])

const result = allWellsFromPlates([plateObj])

expect(result[0].plateObj).toBe(plateObj)
})

it('returns entries for all occupied wells across multiple plates', () => {
const plate0 = buildPlateObj(0, [buildWell('A1', 1), buildWell('B1', 0)])
const plate1 = buildPlateObj(1, [buildWell('A1', 0), buildWell('C1', 3)])

const result = allWellsFromPlates([plate0, plate1])

expect(result).toHaveLength(2)
expect(result[0].well.position.name).toBe('A1')
expect(result[0].plateObj).toBe(plate0)
expect(result[1].well.position.name).toBe('C1')
expect(result[1].plateObj).toBe(plate1)
})
})

describe('handleFailedRequest', () => {
it('emits a danger alert with a formatted message when response contains an array', () => {
const mockEmit = vi.spyOn(eventBus, '$emit')
Expand Down
2 changes: 1 addition & 1 deletion app/frontend/javascript/shared/transfersCreators.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const transferPlatesToPlatesCreator = function (transfers, extraParams = (_) =>
source_plate: transfers[i].plateObj.plate.uuid,
pool_index: transfers[i].plateObj.index + 1,
source_asset: transfers[i].well.uuid,
outer_request: transfers[i].request.uuid,
outer_request: transfers[i].request?.uuid ?? null,
Comment thread
andrewsparkes marked this conversation as resolved.
new_target: { location: transfers[i].targetWell },
...extraParams(transfers[i]),
}
Expand Down
27 changes: 22 additions & 5 deletions app/frontend/javascript/shared/transfersLayouts.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,12 @@ const quadrantTransfers = function (requestsWithPlates) {
// duplicatedRequests array.
//
// Note: Indexes are calculated for 96 wells plates only.
const buildPlatesMatrix = function (requestsWithPlates, maxPlates, maxWellsPerPlate) {
const buildPlatesMatrix = function (requestsWithPlates, maxPlates, maxWellsPerPlate, transferAllWells = false) {
const platesMatrix = buildArray(maxPlates, () => new Array(maxWellsPerPlate))
const duplicatedRequests = []
for (let i = 0; i < requestsWithPlates.length; i++) {
const { request, well, plateObj } = requestsWithPlates[i]
if (request === undefined) {
if (request === undefined && !transferAllWells) {
continue
}
const wellIndex = nameToIndex(well.position.name, 8)
Expand Down Expand Up @@ -167,11 +167,12 @@ const buildSequentialTransfersArray = function (transferRequests) {
const transfers = new Array(transferRequests.length)
for (let i = 0; i < transferRequests.length; i++) {
const requestWithPlate = transferRequests[i]
const targetWell = indexToName(i, 8)
transfers[i] = {
request: requestWithPlate.request,
well: requestWithPlate.well,
plateObj: requestWithPlate.plateObj,
targetWell: indexToName(i, 8),
targetWell: targetWell,
}
}
return transfers
Expand Down Expand Up @@ -273,8 +274,8 @@ const buildSequentialTubesTransfersArray = function (transferRequests) {
// |C1|C2| | | | |P1D1|P2A1|P2D3
// +--+--+--~ +--+--+--~ +----+----+----~
// |D1| |D3 | |D2|D3 |P1C2|P2B2|
const sequentialTransfers = function (requestsWithPlates) {
const { platesMatrix, duplicatedRequests } = buildPlatesMatrix(requestsWithPlates, 10, 96)
const sequentialTransfers = function (requestsWithPlates, transferAllWells = false) {
const { platesMatrix, duplicatedRequests } = buildPlatesMatrix(requestsWithPlates, 10, 96, transferAllWells)
const transferRequests = platesMatrix.flat()
const validTransfers = buildSequentialTransfersArray(transferRequests)
const duplicatedTransfers = buildSequentialTransfersArray(duplicatedRequests)
Expand Down Expand Up @@ -337,6 +338,21 @@ const transfersFromRequests = function (requestsWithPlates, transfersLayout) {
return { valid: validTransfers, duplicated: duplicatedTransfers }
}

// Receives an array of allWellsWithAliquots and a transfer layout name (developed
// for 'sequential' only).
// Returns an object containing an array of valid transfers and an array of
// duplicated transfers.
// Throws an error if the transfers layout string is not mapped to a transfer
// function.
const transfersFromAllWells = function (allWellsWithAliquots, transfersLayout) {
const transferFunction = transferFunctions[transfersLayout]
if (transferFunction === undefined) {
throw `Invalid transfers layout name: ${transfersLayout}`
}
const { validTransfers, duplicatedTransfers } = transferFunction(allWellsWithAliquots, true)
return { valid: validTransfers, duplicated: duplicatedTransfers }
}

// Receives an array of potential transfers and a transfer layout name
// (valid options: 'sequentialtubes').
// Returns an object containing an array of valid transfers
Expand Down Expand Up @@ -367,6 +383,7 @@ const transfersForTubes = function (validTubes) {

export {
transfersFromRequests,
transfersFromAllWells,
transfersForTubes,
buildPlatesMatrix,
buildLibrarySplitPlatesMatrix,
Expand Down
33 changes: 33 additions & 0 deletions app/frontend/javascript/shared/transfersLayouts.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
transfersFromRequests,
transfersFromAllWells,
buildPlatesMatrix,
buildLibrarySplitPlatesMatrix,
buildSequentialLibrarySplitTransfersArray,
Expand Down Expand Up @@ -281,4 +282,36 @@ describe('transfersLayouts.js', () => {
])
})
})

describe('#transfersFromAllWells', () => {
const allWells = [
{ request: undefined, well: well1, plateObj: plateObj1 },
{ request: undefined, well: well2, plateObj: plateObj2 },
]

it('throws an error if invalid layout is provided', () => {
expect(() => transfersFromAllWells(allWells, 'invalid')).toThrow('Invalid transfers layout name: invalid')
})

it('creates the correct transfers with sequential layout', () => {
const transfersResults = transfersFromAllWells(allWells, 'sequential')

expect(transfersResults.valid).toEqual([
{
request: undefined,
well: well1,
plateObj: plateObj1,
targetWell: 'A1',
},
{
request: undefined,
well: well2,
plateObj: plateObj2,
targetWell: 'B1',
},
])

expect(transfersResults.duplicated).toEqual([])
})
})
})
Loading
Loading