From 644dcc36f6297e74eb72c90bdd57637e054ee137 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 23 Apr 2024 09:58:29 +0200 Subject: [PATCH] Integrate `mll-lab/microplate` --- CHANGELOG.md | 4 + composer.json | 8 +- src/Microplate/AbstractMicroplate.php | 148 ++++ src/Microplate/AbstractSection.php | 27 + src/Microplate/Casts/Coordinates96Well.php | 40 + src/Microplate/CoordinateSystem.php | 128 +++ src/Microplate/CoordinateSystem12Well.php | 19 + src/Microplate/CoordinateSystem48Well.php | 19 + src/Microplate/CoordinateSystem96Well.php | 19 + src/Microplate/Coordinates.php | 146 ++++ src/Microplate/Enums/FlowDirection.php | 26 + .../Exceptions/MicroplateIsFullException.php | 11 + .../SectionDoesNotExistException.php | 5 + .../Exceptions/SectionIsFullException.php | 11 + .../Exceptions/UnexpectedFlowDirection.php | 13 + .../Exceptions/WellNotEmptyException.php | 5 + src/Microplate/FullColumnSection.php | 93 ++ src/Microplate/Microplate.php | 129 +++ src/Microplate/MicroplateSet/Location.php | 24 + .../MicroplateSet/MicroplateSet.php | 53 ++ .../MicroplateSet/MicroplateSetAB.php | 21 + .../MicroplateSet/MicroplateSetABCD.php | 21 + .../MicroplateSet/MicroplateSetABCDE.php | 21 + src/Microplate/Scalars/Column96Well.php | 56 ++ src/Microplate/Scalars/Row96Well.php | 15 + src/Microplate/Section.php | 27 + src/Microplate/SectionedMicroplate.php | 72 ++ src/Microplate/WellWithCoordinates.php | 26 + src/QxManager/FilledWell.php | 4 +- src/QxManager/QxManagerSampleSheet.php | 8 +- tests/Microplate/CoordinateSystemTest.php | 39 + tests/Microplate/CoordinatesTest.php | 824 ++++++++++++++++++ .../MicroplateSet/MicroplateSetABCDETest.php | 48 + .../MicroplateSet/MicroplateSetABCDTest.php | 153 ++++ .../MicroplateSet/MicroplateSetABTest.php | 159 ++++ tests/Microplate/MicroplateSetTest.php | 21 + tests/Microplate/MicroplateTest.php | 273 ++++++ tests/Microplate/Scalars/Column96WellTest.php | 59 ++ tests/Microplate/Scalars/Row96WellTest.php | 68 ++ .../FullColumnSectionTest.php | 140 +++ .../SectionedMicroplate/SectionTest.php | 33 + .../SectionedMicroplateTest.php | 48 + tests/QxManager/FilledWellTest.php | 4 +- tests/QxManager/QxManagerSampleSheetTest.php | 6 +- 44 files changed, 3062 insertions(+), 12 deletions(-) create mode 100644 src/Microplate/AbstractMicroplate.php create mode 100644 src/Microplate/AbstractSection.php create mode 100644 src/Microplate/Casts/Coordinates96Well.php create mode 100644 src/Microplate/CoordinateSystem.php create mode 100644 src/Microplate/CoordinateSystem12Well.php create mode 100644 src/Microplate/CoordinateSystem48Well.php create mode 100644 src/Microplate/CoordinateSystem96Well.php create mode 100644 src/Microplate/Coordinates.php create mode 100644 src/Microplate/Enums/FlowDirection.php create mode 100644 src/Microplate/Exceptions/MicroplateIsFullException.php create mode 100644 src/Microplate/Exceptions/SectionDoesNotExistException.php create mode 100644 src/Microplate/Exceptions/SectionIsFullException.php create mode 100644 src/Microplate/Exceptions/UnexpectedFlowDirection.php create mode 100644 src/Microplate/Exceptions/WellNotEmptyException.php create mode 100644 src/Microplate/FullColumnSection.php create mode 100644 src/Microplate/Microplate.php create mode 100644 src/Microplate/MicroplateSet/Location.php create mode 100644 src/Microplate/MicroplateSet/MicroplateSet.php create mode 100644 src/Microplate/MicroplateSet/MicroplateSetAB.php create mode 100644 src/Microplate/MicroplateSet/MicroplateSetABCD.php create mode 100644 src/Microplate/MicroplateSet/MicroplateSetABCDE.php create mode 100644 src/Microplate/Scalars/Column96Well.php create mode 100644 src/Microplate/Scalars/Row96Well.php create mode 100644 src/Microplate/Section.php create mode 100644 src/Microplate/SectionedMicroplate.php create mode 100644 src/Microplate/WellWithCoordinates.php create mode 100644 tests/Microplate/CoordinateSystemTest.php create mode 100644 tests/Microplate/CoordinatesTest.php create mode 100644 tests/Microplate/MicroplateSet/MicroplateSetABCDETest.php create mode 100644 tests/Microplate/MicroplateSet/MicroplateSetABCDTest.php create mode 100644 tests/Microplate/MicroplateSet/MicroplateSetABTest.php create mode 100644 tests/Microplate/MicroplateSetTest.php create mode 100644 tests/Microplate/MicroplateTest.php create mode 100644 tests/Microplate/Scalars/Column96WellTest.php create mode 100644 tests/Microplate/Scalars/Row96WellTest.php create mode 100644 tests/Microplate/SectionedMicroplate/FullColumnSectionTest.php create mode 100644 tests/Microplate/SectionedMicroplate/SectionTest.php create mode 100644 tests/Microplate/SectionedMicroplate/SectionedMicroplateTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 59043ac..33f895e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ See [GitHub releases](https://github.com/mll-lab/php-utils/releases). ## Unreleased +### Added + +- Integrate `mll-lab/microplate` + ## v1.12.0 ### Added diff --git a/composer.json b/composer.json index b6055e5..2dac276 100644 --- a/composer.json +++ b/composer.json @@ -18,16 +18,19 @@ "php": "^7.4 || ^8", "ext-calendar": "*", "illuminate/support": "^8.73 || ^9 || ^10", - "mll-lab/microplate": "^6", "mll-lab/str_putcsv": "^1", "nesbot/carbon": "^2.62.1", "thecodingmachine/safe": "^1 || ^2" }, "require-dev": { "ergebnis/composer-normalize": "^2", + "illuminate/database": "^8.73 || ^9 || ^10", "infection/infection": "^0.26 || ^0.27", "jangregor/phpstan-prophecy": "^1", + "mll-lab/graphql-php-scalars": "^6", "mll-lab/php-cs-fixer-config": "^5", + "nunomaduro/larastan": "^1 || ^2", + "orchestra/testbench": "^5 || ^6 || ^7 || ^8", "phpstan/extension-installer": "^1", "phpstan/phpstan": "^1", "phpstan/phpstan-deprecation-rules": "^1", @@ -37,6 +40,9 @@ "rector/rector": "^0.17", "thecodingmachine/phpstan-safe-rule": "^1.2" }, + "suggest": { + "mll-lab/graphql-php-scalars": "To use the provided scalar types for GraphQL servers, requires version ^6" + }, "autoload": { "psr-4": { "MLL\\Utils\\": "src/" diff --git a/src/Microplate/AbstractMicroplate.php b/src/Microplate/AbstractMicroplate.php new file mode 100644 index 0000000..40415bf --- /dev/null +++ b/src/Microplate/AbstractMicroplate.php @@ -0,0 +1,148 @@ + + */ +abstract class AbstractMicroplate +{ + public const EMPTY_WELL = null; + + /** @var TCoordinateSystem */ + public CoordinateSystem $coordinateSystem; + + /** @param TCoordinateSystem $coordinateSystem */ + public function __construct(CoordinateSystem $coordinateSystem) + { + $this->coordinateSystem = $coordinateSystem; + } + + /** @return WellsCollection */ + abstract public function wells(): Collection; + + /** + * @param Coordinates $coordinate + * + * @return TWell|null + */ + public function well(Coordinates $coordinate) + { + return $this->wells()[$coordinate->toString()]; + } + + /** @param Coordinates $coordinate */ + public function isWellEmpty(Coordinates $coordinate): bool + { + return $this->well($coordinate) === self::EMPTY_WELL; + } + + /** @return WellsCollection */ + public function sortedWells(FlowDirection $flowDirection): Collection + { + return $this->wells()->sortBy( + /** + * @param TWell $content + */ + function ($content, string $key) use ($flowDirection): string { + switch ($flowDirection->value) { + case FlowDirection::ROW: + return $key; + case FlowDirection::COLUMN: + $coordinates = Coordinates::fromString($key, $this->coordinateSystem); + + return $coordinates->column . $coordinates->row; + // @codeCoverageIgnoreStart all Enums are listed and this should never happen + default: + throw new UnexpectedFlowDirection($flowDirection); + // @codeCoverageIgnoreEnd + } + }, + SORT_NATURAL + ); + } + + /** @return Collection */ + public function freeWells(): Collection + { + return $this->wells()->filter( + /** + * @param TWell $content + */ + static fn ($content): bool => $content === self::EMPTY_WELL + ); + } + + /** @return Collection */ + public function filledWells(): Collection + { + return $this->wells()->filter( + /** + * @param TWell $content + */ + static fn ($content): bool => $content !== self::EMPTY_WELL + ); + } + + /** @return callable(TWell|null $content, string $coordinatesString): bool */ + public function matchRow(string $row): callable + { + return function ($content, string $coordinatesString) use ($row): bool { + $coordinates = Coordinates::fromString($coordinatesString, $this->coordinateSystem); + + return $coordinates->row === $row; + }; + } + + /** @return callable(TWell|null $content, string $coordinatesString): bool */ + public function matchColumn(int $column): callable + { + return function ($content, string $coordinatesString) use ($column): bool { + $coordinates = Coordinates::fromString($coordinatesString, $this->coordinateSystem); + + return $coordinates->column === $column; + }; + } + + /** + * @deprecated use toWellWithCoordinatesMapper + * + * @return callable(TWell $content, string $coordinatesString): WellWithCoordinates + */ + public function toWellWithCoordinateMapper(): callable + { + return $this->toWellWithCoordinatesMapper(); + } + + /** @return callable(TWell $content, string $coordinatesString): WellWithCoordinates */ + public function toWellWithCoordinatesMapper(): callable + { + return fn ($content, string $coordinatesString): WellWithCoordinates => new WellWithCoordinates( + $content, + Coordinates::fromString($coordinatesString, $this->coordinateSystem) + ); + } + + /** + * Are all filled wells placed in a single connected block without gaps between them? + * + * Returns `false` if all wells are empty. + */ + public function isConsecutive(FlowDirection $flowDirection): bool + { + $positions = $this->filledWells() + ->map( + /** @param TWell $content */ + fn ($content, string $coordinatesString): int => Coordinates::fromString($coordinatesString, $this->coordinateSystem)->position($flowDirection) + ); + + return ($positions->max() - $positions->min() + 1) === $positions->count(); + } +} diff --git a/src/Microplate/AbstractSection.php b/src/Microplate/AbstractSection.php new file mode 100644 index 0000000..f216f25 --- /dev/null +++ b/src/Microplate/AbstractSection.php @@ -0,0 +1,27 @@ + */ + public SectionedMicroplate $sectionedMicroplate; + + /** @var Collection */ + public Collection $sectionItems; + + /** @param SectionedMicroplate $sectionedMicroplate */ + public function __construct(SectionedMicroplate $sectionedMicroplate) + { + $this->sectionedMicroplate = $sectionedMicroplate; + $this->sectionItems = new Collection(); + } + + /** @param TSectionWell $content */ + abstract public function addWell($content): void; +} diff --git a/src/Microplate/Casts/Coordinates96Well.php b/src/Microplate/Casts/Coordinates96Well.php new file mode 100644 index 0000000..510ded1 --- /dev/null +++ b/src/Microplate/Casts/Coordinates96Well.php @@ -0,0 +1,40 @@ +, Coordinates> + */ +final class Coordinates96Well implements CastsAttributes +{ + /** + * @param Model $model + * @param string $key + * @param array $attributes + * + * @return Coordinates + */ + public function get($model, $key, $value, $attributes): Coordinates + { + assert(is_string($value)); + + return Coordinates::fromString($value, new CoordinateSystem96Well()); + } + + /** + * @param Model $model + * @param string $key + * @param array $attributes + */ + public function set($model, $key, $value, $attributes): string + { + assert($value instanceof Coordinates); + + return $value->toString(); + } +} diff --git a/src/Microplate/CoordinateSystem.php b/src/Microplate/CoordinateSystem.php new file mode 100644 index 0000000..f91e144 --- /dev/null +++ b/src/Microplate/CoordinateSystem.php @@ -0,0 +1,128 @@ + */ + abstract public function rows(): array; + + /** @return list */ + abstract public function columns(): array; + + /** + * List of columns, 0-padded to all have the same length. + * + * @return list + */ + public function paddedColumns(): array + { + $paddedColumns = []; + foreach ($this->columns() as $column) { + $paddedColumns[] = $this->padColumn($column); + } + + return $paddedColumns; + } + + /** 0-pad column to be as long as the longest column in the coordinate system. */ + public function padColumn(int $column): string + { + $maxColumnLength = strlen((string) $this->columnsCount()); + + return str_pad((string) $column, $maxColumnLength, '0', STR_PAD_LEFT); + } + + public function rowForRowFlowPosition(int $position): string + { + return $this->rows()[floor(($position - 1) / $this->columnsCount())]; + } + + public function rowForColumnFlowPosition(int $position): string + { + return $this->rows()[($position - 1) % $this->rowsCount()]; + } + + public function columnForRowFlowPosition(int $position): int + { + return $this->columns()[($position - 1) % $this->columnsCount()]; + } + + public function columnForColumnFlowPosition(int $position): int + { + return $this->columns()[floor(($position - 1) / $this->rowsCount())]; + } + + public function positionsCount(): int + { + return $this->columnsCount() * $this->rowsCount(); + } + + /** + * Returns all possible coordinates of the system, ordered by column then row. + * + * e.g. A1, A2, B1, B2 + * + * @return iterable> + */ + public function all(): iterable + { + foreach ($this->columns() as $column) { + foreach ($this->rows() as $row) { + yield new Coordinates($row, $column, $this); + } + } + } + + /** + * Returns the coordinates of the first row and first column. + * + * @return Coordinates<$this> + */ + public function first(): Coordinates + { + $firstRow = Arr::first($this->rows()); + if (! is_string($firstRow)) { + throw new \Exception('First row must be string.'); + } + + $firstColumn = Arr::first($this->columns()); + if (! is_int($firstColumn)) { + throw new \Exception('First column must be string.'); + } + + return new Coordinates($firstRow, $firstColumn, $this); + } + + /** + * Returns the coordinates of the last row and last column. + * + * @return Coordinates<$this> + */ + public function last(): Coordinates + { + $lastRow = Arr::last($this->rows()); + if (! is_string($lastRow)) { + throw new \Exception('Last row must be string.'); + } + + $lastColumn = Arr::last($this->columns()); + if (! is_int($lastColumn)) { + throw new \Exception('Last column must be string.'); + } + + return new Coordinates($lastRow, $lastColumn, $this); + } + + public function rowsCount(): int + { + return count($this->rows()); + } + + public function columnsCount(): int + { + return count($this->columns()); + } +} diff --git a/src/Microplate/CoordinateSystem12Well.php b/src/Microplate/CoordinateSystem12Well.php new file mode 100644 index 0000000..93d2a02 --- /dev/null +++ b/src/Microplate/CoordinateSystem12Well.php @@ -0,0 +1,19 @@ +rows(); + if (! in_array($row, $rows, true)) { + $rowList = \implode(',', $rows); + throw new \InvalidArgumentException("Expected a row with value of {$rowList}, got {$row}."); + } + $this->row = $row; + + $columns = $coordinateSystem->columns(); + if (! in_array($column, $columns, true)) { + $columnsList = \implode(',', $columns); + throw new \InvalidArgumentException("Expected a column with value of {$columnsList}, got {$column}."); + } + $this->column = $column; + + $this->coordinateSystem = $coordinateSystem; + } + + /** + * @template TCoord of CoordinateSystem + * + * @param TCoord $coordinateSystem + * + * @return static + */ + public static function fromString(string $coordinatesString, CoordinateSystem $coordinateSystem): self + { + $rows = $coordinateSystem->rows(); + $rowsOptions = \implode('|', $rows); + + $columns = [ + ...$coordinateSystem->columns(), + ...$coordinateSystem->paddedColumns(), + ]; + $columnsOptions = \implode('|', $columns); + + $valid = preg_match( + "/^({$rowsOptions})({$columnsOptions})\$/", + $coordinatesString, + $matches + ); + + if ($valid === 0) { + $firstValidExample = Arr::first($rows) . Arr::first($columns); + $lastValidExample = Arr::last($rows) . Arr::last($columns); + $coordinateSystemClass = \get_class($coordinateSystem); + throw new \InvalidArgumentException("Expected coordinates between {$firstValidExample} and {$lastValidExample} for {$coordinateSystemClass}, got: {$coordinatesString}."); + } + + return new self($matches[1], (int) $matches[2], $coordinateSystem); + } + + public function toString(): string + { + return $this->row . $this->column; + } + + /** Format the coordinates with the column 0-padded so all are the same length. */ + public function toPaddedString(): string + { + return $this->row . $this->coordinateSystem->padColumn($this->column); + } + + /** + * @param TCoordinateSystem $coordinateSystem + * + * @return static + */ + public static function fromPosition(int $position, FlowDirection $direction, CoordinateSystem $coordinateSystem): self + { + self::assertPositionInRange($coordinateSystem, $position); + + switch ($direction->value) { + case FlowDirection::COLUMN: + return new self( + $coordinateSystem->rowForColumnFlowPosition($position), + $coordinateSystem->columnForColumnFlowPosition($position), + $coordinateSystem + ); + + case FlowDirection::ROW: + return new self( + $coordinateSystem->rowForRowFlowPosition($position), + $coordinateSystem->columnForRowFlowPosition($position), + $coordinateSystem + ); + // @codeCoverageIgnoreStart all Enums are listed and this should never happen + default: + throw new UnexpectedFlowDirection($direction); + // @codeCoverageIgnoreEnd + } + } + + public function position(FlowDirection $direction): int + { + /** @var int $rowIndex Must be found, since __construct enforces $this->row is valid */ + $rowIndex = array_search($this->row, $this->coordinateSystem->rows(), true); + + /** @var int $columnIndex Must be found, since __construct enforces $this->column is valid */ + $columnIndex = array_search($this->column, $this->coordinateSystem->columns(), true); + + switch ($direction->value) { + case FlowDirection::ROW: + return $rowIndex * \count($this->coordinateSystem->columns()) + $columnIndex + 1; + case FlowDirection::COLUMN: + return $columnIndex * \count($this->coordinateSystem->rows()) + $rowIndex + 1; + // @codeCoverageIgnoreStart all Enums are listed and this should never happen + default: + throw new UnexpectedFlowDirection($direction); + // @codeCoverageIgnoreEnd + } + } + + private static function assertPositionInRange(CoordinateSystem $coordinateSystem, int $position): void + { + if (! in_array($position, range(self::MIN_POSITION, $coordinateSystem->positionsCount()), true)) { + throw new \InvalidArgumentException("Expected a position between 1-{$coordinateSystem->positionsCount()}, got: {$position}."); + } + } +} diff --git a/src/Microplate/Enums/FlowDirection.php b/src/Microplate/Enums/FlowDirection.php new file mode 100644 index 0000000..419b166 --- /dev/null +++ b/src/Microplate/Enums/FlowDirection.php @@ -0,0 +1,26 @@ +value = $value; + } + + public static function ROW(): self + { + return new self(self::ROW); + } + + public static function COLUMN(): self + { + return new self(self::COLUMN); + } +} diff --git a/src/Microplate/Exceptions/MicroplateIsFullException.php b/src/Microplate/Exceptions/MicroplateIsFullException.php new file mode 100644 index 0000000..fb436d4 --- /dev/null +++ b/src/Microplate/Exceptions/MicroplateIsFullException.php @@ -0,0 +1,11 @@ +value}."); + } +} diff --git a/src/Microplate/Exceptions/WellNotEmptyException.php b/src/Microplate/Exceptions/WellNotEmptyException.php new file mode 100644 index 0000000..f5db0d6 --- /dev/null +++ b/src/Microplate/Exceptions/WellNotEmptyException.php @@ -0,0 +1,5 @@ + + */ +final class FullColumnSection extends AbstractSection +{ + public function __construct(SectionedMicroplate $sectionedMicroplate) + { + parent::__construct($sectionedMicroplate); + $this->growSection(); + } + + /** + * @param TSectionWell $content + * + * @throws MicroplateIsFullException + * @throws SectionIsFullException + */ + public function addWell($content): void + { + if ($this->sectionedMicroplate->freeWells()->isEmpty()) { + throw new MicroplateIsFullException(); + } + + $nextReservedWell = $this->nextReservedWell(); + if ($nextReservedWell !== false) { + $this->sectionItems[$nextReservedWell] = $content; + + return; + } + + $this->growSection(); + + $nextReservedWell = $this->nextReservedWell(); + assert(is_int($nextReservedWell), 'Guaranteed to be found after we grew the section'); + + $this->sectionItems[$nextReservedWell] = $content; + } + + /** + * Grows the section by initializing a new column with empty wells. + * + * @throws SectionIsFullException + */ + private function growSection(): void + { + if (! $this->sectionCanGrow()) { + throw new SectionIsFullException(); + } + + foreach ($this->sectionedMicroplate->coordinateSystem->rows() as $row) { + $this->sectionItems->push(AbstractMicroplate::EMPTY_WELL); + } + } + + /** @return false|int */ + private function nextReservedWell() + { + $search = $this->sectionItems->search(AbstractMicroplate::EMPTY_WELL); + assert($search === false || is_int($search)); + + return $search; + } + + private function sectionCanGrow(): bool + { + $totalReservedColumns = $this->sectionedMicroplate + ->sections + ->sum(static fn (self $section): int => $section->reservedColumns()); + + $availableColumns = $this->sectionedMicroplate + ->coordinateSystem + ->columnsCount(); + + return $totalReservedColumns < $availableColumns; + } + + private function reservedColumns(): int + { + return (int) ceil($this->sectionItems->count() / $this->sectionedMicroplate->coordinateSystem->rowsCount()); + } +} diff --git a/src/Microplate/Microplate.php b/src/Microplate/Microplate.php new file mode 100644 index 0000000..41a5ea1 --- /dev/null +++ b/src/Microplate/Microplate.php @@ -0,0 +1,129 @@ + + * + * @phpstan-type WellsCollection Collection + */ +final class Microplate extends AbstractMicroplate +{ + /** @var WellsCollection */ + protected Collection $wells; + + /** @param TCoordinateSystem $coordinateSystem */ + public function __construct(CoordinateSystem $coordinateSystem) + { + parent::__construct($coordinateSystem); + + $this->clearWells(); + } + + /** @return WellsCollection */ + public function wells(): Collection + { + return $this->wells; + } + + /** @param Coordinates $coordinates */ + public static function position(Coordinates $coordinates, FlowDirection $direction): int + { + return $coordinates->position($direction); + } + + /** + * @param TWell $content + * @param Coordinates $coordinates + * + * @throws WellNotEmptyException + */ + public function addWell(Coordinates $coordinates, $content): void + { + $this->assertIsWellEmpty($coordinates, $content); + $this->setWell($coordinates, $content); + } + + /** + * Set the well at the given coordinates to the given content. + * + * @param Coordinates $coordinates + * @param TWell $content + */ + public function setWell(Coordinates $coordinates, $content): void + { + $this->wells[$coordinates->toString()] = $content; + } + + /** + * @param Coordinates $coordinates + * @param TWell $content + * + * @throws WellNotEmptyException + */ + private function assertIsWellEmpty(Coordinates $coordinates, $content): void + { + if (! $this->isWellEmpty($coordinates)) { + throw new WellNotEmptyException( + 'Well with coordinates "' . $coordinates->toString() . '" is not empty. Use setWell() to overwrite the coordinate. Well content "' . serialize($content) . '" was not added.' + ); + } + } + + /** Clearing the wells will reinitialize all well position of the coordinate system. */ + public function clearWells(): void + { + /** + * Flow direction is irrelevant during initialization, it is not a property of + * a plate but rather a property of the access to the plate. + */ + + /** @var array $wells */ + $wells = []; + foreach ($this->coordinateSystem->all() as $coordinate) { + $wells[$coordinate->toString()] = self::EMPTY_WELL; + } + + $this->wells = new Collection($wells); + } + + /** + * @param TWell $content + * + *@throws MicroplateIsFullException + * + * @return Coordinates + */ + public function addToNextFreeWell($content, FlowDirection $flowDirection): Coordinates + { + $coordinates = $this->nextFreeWellCoordinates($flowDirection); + $this->wells[$coordinates->toString()] = $content; + + return $coordinates; + } + + /** + *@throws MicroplateIsFullException + * + * @return Coordinates + */ + public function nextFreeWellCoordinates(FlowDirection $flowDirection): Coordinates + { + $coordinatesString = $this->sortedWells($flowDirection) + ->search(self::EMPTY_WELL); + + if (! is_string($coordinatesString)) { + throw new MicroplateIsFullException(); + } + + return Coordinates::fromString($coordinatesString, $this->coordinateSystem); + } +} diff --git a/src/Microplate/MicroplateSet/Location.php b/src/Microplate/MicroplateSet/Location.php new file mode 100644 index 0000000..efe4d09 --- /dev/null +++ b/src/Microplate/MicroplateSet/Location.php @@ -0,0 +1,24 @@ + */ + public Coordinates $coordinates; + + /** @param \MLL\Utils\Microplate\Coordinates $coordinates */ + public function __construct(Coordinates $coordinates, string $plateID) + { + $this->coordinates = $coordinates; + $this->plateID = $plateID; + } +} diff --git a/src/Microplate/MicroplateSet/MicroplateSet.php b/src/Microplate/MicroplateSet/MicroplateSet.php new file mode 100644 index 0000000..da9f7b3 --- /dev/null +++ b/src/Microplate/MicroplateSet/MicroplateSet.php @@ -0,0 +1,53 @@ +coordinateSystem = $coordinateSystem; + } + + /** @return list */ + abstract public function plateIDs(): array; + + public function plateCount(): int + { + return count($this->plateIDs()); + } + + public function positionsCount(): int + { + return $this->coordinateSystem->positionsCount() * $this->plateCount(); + } + + /** @return Location */ + public function locationFromPosition(int $setPosition, FlowDirection $direction): Location + { + $positionsCount = $this->positionsCount(); + if ($setPosition > $positionsCount || $setPosition < Coordinates::MIN_POSITION) { + throw new \OutOfRangeException("Expected a position between 1-{$positionsCount}, got: {$setPosition}."); + } + + $plateIndex = (int) floor(($setPosition - 1) / $this->coordinateSystem->positionsCount()); + $positionOnSinglePlate = $setPosition - ($plateIndex * $this->coordinateSystem->positionsCount()); + + /** @phpstan-ignore-next-line Generic inference is too weak to recognize this code is correct */ + return new Location( + Coordinates::fromPosition($positionOnSinglePlate, $direction, $this->coordinateSystem), + $this->plateIDs()[$plateIndex] + ); + } +} diff --git a/src/Microplate/MicroplateSet/MicroplateSetAB.php b/src/Microplate/MicroplateSet/MicroplateSetAB.php new file mode 100644 index 0000000..cd95022 --- /dev/null +++ b/src/Microplate/MicroplateSet/MicroplateSetAB.php @@ -0,0 +1,21 @@ + + */ +final class MicroplateSetAB extends MicroplateSet +{ + /** Duplicates @see MicroplateSet::plateCount() for static contexts. */ + public const PLATE_COUNT = 2; + + public function plateIDs(): array + { + return ['A', 'B']; + } +} diff --git a/src/Microplate/MicroplateSet/MicroplateSetABCD.php b/src/Microplate/MicroplateSet/MicroplateSetABCD.php new file mode 100644 index 0000000..1b1a06a --- /dev/null +++ b/src/Microplate/MicroplateSet/MicroplateSetABCD.php @@ -0,0 +1,21 @@ + + */ +final class MicroplateSetABCD extends MicroplateSet +{ + /** Duplicates @see MicroplateSet::plateCount() for static contexts. */ + public const PLATE_COUNT = 4; + + public function plateIDs(): array + { + return ['A', 'B', 'C', 'D']; + } +} diff --git a/src/Microplate/MicroplateSet/MicroplateSetABCDE.php b/src/Microplate/MicroplateSet/MicroplateSetABCDE.php new file mode 100644 index 0000000..1a67c36 --- /dev/null +++ b/src/Microplate/MicroplateSet/MicroplateSetABCDE.php @@ -0,0 +1,21 @@ + + */ +final class MicroplateSetABCDE extends MicroplateSet +{ + /** Duplicates @see MicroplateSet::plateCount() for static contexts. */ + public const PLATE_COUNT = 5; + + public function plateIDs(): array + { + return ['A', 'B', 'C', 'D', 'E']; + } +} diff --git a/src/Microplate/Scalars/Column96Well.php b/src/Microplate/Scalars/Column96Well.php new file mode 100644 index 0000000..b4cdf8e --- /dev/null +++ b/src/Microplate/Scalars/Column96Well.php @@ -0,0 +1,56 @@ +isValueInExpectedRange($value)) { + return $value; + } + + $notInRange = Utils::printSafe($value); + throw new \InvalidArgumentException("Value not in range: {$notInRange}."); + } + + public function parseValue($value) + { + if (is_int($value) && $this->isValueInExpectedRange($value)) { + return $value; + } + + $notInRange = Utils::printSafe($value); + throw new Error("Value not in range: {$notInRange}."); + } + + public function parseLiteral(Node $valueNode, ?array $variables = null) + { + if ($valueNode instanceof IntValueNode) { + $value = (int) $valueNode->value; + if ($this->isValueInExpectedRange($value)) { + return $value; + } + } + + $notInRange = Printer::doPrint($valueNode); + throw new Error("Value not in range: {$notInRange}.", $valueNode); + } + + private function isValueInExpectedRange(int $value): bool + { + return $value <= self::MAX_INT && $value >= self::MIN_INT; + } +} diff --git a/src/Microplate/Scalars/Row96Well.php b/src/Microplate/Scalars/Row96Well.php new file mode 100644 index 0000000..736770e --- /dev/null +++ b/src/Microplate/Scalars/Row96Well.php @@ -0,0 +1,15 @@ + + */ +final class Section extends AbstractSection +{ + /** + * @param TSectionWell $content + * + * @throws MicroplateIsFullException + */ + public function addWell($content): void + { + if ($this->sectionedMicroplate->freeWells()->isEmpty()) { + throw new MicroplateIsFullException(); + } + + $this->sectionItems->push($content); + } +} diff --git a/src/Microplate/SectionedMicroplate.php b/src/Microplate/SectionedMicroplate.php new file mode 100644 index 0000000..8111b43 --- /dev/null +++ b/src/Microplate/SectionedMicroplate.php @@ -0,0 +1,72 @@ + + */ +final class SectionedMicroplate extends AbstractMicroplate +{ + /** @var Collection */ + public Collection $sections; + + /** @param TCoordinateSystem $coordinateSystem */ + public function __construct(CoordinateSystem $coordinateSystem) + { + parent::__construct($coordinateSystem); + + $this->clearSections(); + } + + /** + * @template TAddSection of TSection + * + * @param class-string $sectionClass + * + * @return TAddSection + */ + public function addSection(string $sectionClass): AbstractSection + { + // @phpstan-ignore-next-line generic mismatch + return $this->sections[] = new $sectionClass($this); + } + + /** @param TSection $section */ + public function removeSection(AbstractSection $section): void + { + foreach ($this->sections as $i => $s) { + if ($s === $section) { + unset($this->sections[$i]); + } + } + } + + public function wells(): Collection + { + return $this->sections + ->map(fn (AbstractSection $section): Collection => $section->sectionItems) + ->flatten(1) + ->values() + ->zip($this->coordinateSystem->all()) + ->map(fn (Collection $mapping): array => $mapping->all()) + ->mapWithKeys(function (array $mapping): array { + [$sectionItem, $coordinates] = $mapping; + assert($coordinates instanceof Coordinates); + + return [$coordinates->toString() => $sectionItem]; + }); + } + + public function clearSections(): void + { + $this->sections = new Collection(); + } +} diff --git a/src/Microplate/WellWithCoordinates.php b/src/Microplate/WellWithCoordinates.php new file mode 100644 index 0000000..c332c50 --- /dev/null +++ b/src/Microplate/WellWithCoordinates.php @@ -0,0 +1,26 @@ + */ + public Coordinates $coordinates; + + /** + * @param TWell $content + * @param Coordinates $coordinates + */ + public function __construct($content, Coordinates $coordinates) + { + $this->content = $content; + $this->coordinates = $coordinates; + } +} diff --git a/src/QxManager/FilledWell.php b/src/QxManager/FilledWell.php index ff16603..dd5484b 100644 --- a/src/QxManager/FilledWell.php +++ b/src/QxManager/FilledWell.php @@ -2,8 +2,8 @@ namespace MLL\Utils\QxManager; -use Mll\Microplate\Coordinates; -use Mll\Microplate\CoordinateSystem96Well; +use MLL\Utils\Microplate\Coordinates; +use MLL\Utils\Microplate\CoordinateSystem96Well; class FilledWell { diff --git a/src/QxManager/QxManagerSampleSheet.php b/src/QxManager/QxManagerSampleSheet.php index 2eca97b..e445397 100644 --- a/src/QxManager/QxManagerSampleSheet.php +++ b/src/QxManager/QxManagerSampleSheet.php @@ -3,10 +3,10 @@ namespace MLL\Utils\QxManager; use Carbon\CarbonInterface; -use Mll\Microplate\Coordinates; -use Mll\Microplate\CoordinateSystem96Well; -use Mll\Microplate\Enums\FlowDirection; -use Mll\Microplate\Microplate; +use MLL\Utils\Microplate\Coordinates; +use MLL\Utils\Microplate\CoordinateSystem96Well; +use MLL\Utils\Microplate\Enums\FlowDirection; +use MLL\Utils\Microplate\Microplate; use MLL\Utils\StringUtil; class QxManagerSampleSheet diff --git a/tests/Microplate/CoordinateSystemTest.php b/tests/Microplate/CoordinateSystemTest.php new file mode 100644 index 0000000..923b61a --- /dev/null +++ b/tests/Microplate/CoordinateSystemTest.php @@ -0,0 +1,39 @@ +first(); + self::assertSame($expectedFirst, $actualFirst->toString()); + self::assertSame($coordinateSystem, $actualFirst->coordinateSystem); + + $actualLast = $coordinateSystem->last(); + self::assertSame($expectedLast, $actualLast->toString()); + self::assertSame($coordinateSystem, $actualLast->coordinateSystem); + } + + /** @return iterable */ + public static function firstLast(): iterable + { + yield [new CoordinateSystem12Well(), 'A1', 'C4']; + yield [new CoordinateSystem48Well(), 'A1', 'F8']; + yield [new CoordinateSystem96Well(), 'A1', 'H12']; + } + + public function testPositionsCount(): void + { + self::assertSame(CoordinateSystem12Well::POSITIONS_COUNT, (new CoordinateSystem12Well())->positionsCount()); + self::assertSame(CoordinateSystem48Well::POSITIONS_COUNT, (new CoordinateSystem48Well())->positionsCount()); + self::assertSame(CoordinateSystem96Well::POSITIONS_COUNT, (new CoordinateSystem96Well())->positionsCount()); + } +} diff --git a/tests/Microplate/CoordinatesTest.php b/tests/Microplate/CoordinatesTest.php new file mode 100644 index 0000000..8609c70 --- /dev/null +++ b/tests/Microplate/CoordinatesTest.php @@ -0,0 +1,824 @@ +toString()); + } + + /** @dataProvider dataProvider96Well */ + public function testCanConstructFromPosition(string $row, int $column, int $rowFlowPosition, int $columnFlowPosition): void + { + // test for Column-FlowDirection + $coordinates = Coordinates::fromPosition( + $columnFlowPosition, + FlowDirection::COLUMN(), + new CoordinateSystem96Well() + ); + self::assertSame($row, $coordinates->row); + self::assertSame($column, $coordinates->column); + + // test for Row-FlowDirection + $coordinates = Coordinates::fromPosition( + $rowFlowPosition, + FlowDirection::ROW(), + new CoordinateSystem96Well() + ); + self::assertSame($row, $coordinates->row); + self::assertSame($column, $coordinates->column); + } + + /** @dataProvider dataProvider96Well */ + public function testFromCoordinatesString(string $row, int $column, int $rowFlowPosition, int $columnFlowPosition): void + { + $coordinates = Coordinates::fromString($row . $column, new CoordinateSystem96Well()); + self::assertSame($row, $coordinates->row); + self::assertSame($column, $coordinates->column); + } + + /** @dataProvider dataProviderPadded96Well */ + public function testFromPaddedCoordinatesString(string $paddedCoordinates, string $row, int $column): void + { + $coordinatesFromPadded = Coordinates::fromString($paddedCoordinates, new CoordinateSystem96Well()); + self::assertSame($row, $coordinatesFromPadded->row); + self::assertSame($column, $coordinatesFromPadded->column); + self::assertSame($paddedCoordinates, $coordinatesFromPadded->toPaddedString()); + } + + /** + * @return iterable + */ + public static function dataProviderPadded96Well(): iterable + { + yield [ + 'paddedCoordinates' => 'A01', + 'row' => 'A', + 'column' => 1, + ]; + yield [ + 'paddedCoordinates' => 'C05', + 'row' => 'C', + 'column' => 5, + ]; + yield [ + 'paddedCoordinates' => 'H12', + 'row' => 'H', + 'column' => 12, + ]; + yield [ + 'paddedCoordinates' => 'D10', + 'row' => 'D', + 'column' => 10, + ]; + } + + /** @dataProvider dataProvider96Well */ + public function testPosition96Well(string $row, int $column, int $rowFlowPosition, int $columnFlowPosition): void + { + $coordinates = new Coordinates($row, $column, new CoordinateSystem96Well()); + self::assertSame($columnFlowPosition, $coordinates->position(FlowDirection::COLUMN())); + self::assertSame($rowFlowPosition, $coordinates->position(FlowDirection::ROW())); + } + + /** + * @return iterable + */ + public static function dataProvider96Well(): iterable + { + yield [ + 'row' => 'A', + 'column' => 1, + 'rowFlowPosition' => 1, + 'columnFlowPosition' => 1, + ]; + yield [ + 'row' => 'B', + 'column' => 1, + 'rowFlowPosition' => 13, + 'columnFlowPosition' => 2, + ]; + yield [ + 'row' => 'C', + 'column' => 1, + 'rowFlowPosition' => 25, + 'columnFlowPosition' => 3, + ]; + yield [ + 'row' => 'D', + 'column' => 1, + 'rowFlowPosition' => 37, + 'columnFlowPosition' => 4, + ]; + yield [ + 'row' => 'E', + 'column' => 1, + 'rowFlowPosition' => 49, + 'columnFlowPosition' => 5, + ]; + yield [ + 'row' => 'F', + 'column' => 1, + 'rowFlowPosition' => 61, + 'columnFlowPosition' => 6, + ]; + yield [ + 'row' => 'G', + 'column' => 1, + 'rowFlowPosition' => 73, + 'columnFlowPosition' => 7, + ]; + yield [ + 'row' => 'H', + 'column' => 1, + 'rowFlowPosition' => 85, + 'columnFlowPosition' => 8, + ]; + yield [ + 'row' => 'A', + 'column' => 2, + 'rowFlowPosition' => 2, + 'columnFlowPosition' => 9, + ]; + yield [ + 'row' => 'B', + 'column' => 2, + 'rowFlowPosition' => 14, + 'columnFlowPosition' => 10, + ]; + yield [ + 'row' => 'C', + 'column' => 2, + 'rowFlowPosition' => 26, + 'columnFlowPosition' => 11, + ]; + yield [ + 'row' => 'D', + 'column' => 2, + 'rowFlowPosition' => 38, + 'columnFlowPosition' => 12, + ]; + yield [ + 'row' => 'E', + 'column' => 2, + 'rowFlowPosition' => 50, + 'columnFlowPosition' => 13, + ]; + yield [ + 'row' => 'F', + 'column' => 2, + 'rowFlowPosition' => 62, + 'columnFlowPosition' => 14, + ]; + yield [ + 'row' => 'G', + 'column' => 2, + 'rowFlowPosition' => 74, + 'columnFlowPosition' => 15, + ]; + yield [ + 'row' => 'H', + 'column' => 2, + 'rowFlowPosition' => 86, + 'columnFlowPosition' => 16, + ]; + yield [ + 'row' => 'A', + 'column' => 3, + 'rowFlowPosition' => 3, + 'columnFlowPosition' => 17, + ]; + yield [ + 'row' => 'B', + 'column' => 3, + 'rowFlowPosition' => 15, + 'columnFlowPosition' => 18, + ]; + yield [ + 'row' => 'C', + 'column' => 3, + 'rowFlowPosition' => 27, + 'columnFlowPosition' => 19, + ]; + yield [ + 'row' => 'D', + 'column' => 3, + 'rowFlowPosition' => 39, + 'columnFlowPosition' => 20, + ]; + yield [ + 'row' => 'E', + 'column' => 3, + 'rowFlowPosition' => 51, + 'columnFlowPosition' => 21, + ]; + yield [ + 'row' => 'F', + 'column' => 3, + 'rowFlowPosition' => 63, + 'columnFlowPosition' => 22, + ]; + yield [ + 'row' => 'G', + 'column' => 3, + 'rowFlowPosition' => 75, + 'columnFlowPosition' => 23, + ]; + yield [ + 'row' => 'H', + 'column' => 3, + 'rowFlowPosition' => 87, + 'columnFlowPosition' => 24, + ]; + yield [ + 'row' => 'A', + 'column' => 4, + 'rowFlowPosition' => 4, + 'columnFlowPosition' => 25, + ]; + yield [ + 'row' => 'B', + 'column' => 4, + 'rowFlowPosition' => 16, + 'columnFlowPosition' => 26, + ]; + yield [ + 'row' => 'C', + 'column' => 4, + 'rowFlowPosition' => 28, + 'columnFlowPosition' => 27, + ]; + yield [ + 'row' => 'D', + 'column' => 4, + 'rowFlowPosition' => 40, + 'columnFlowPosition' => 28, + ]; + yield [ + 'row' => 'E', + 'column' => 4, + 'rowFlowPosition' => 52, + 'columnFlowPosition' => 29, + ]; + yield [ + 'row' => 'F', + 'column' => 4, + 'rowFlowPosition' => 64, + 'columnFlowPosition' => 30, + ]; + yield [ + 'row' => 'G', + 'column' => 4, + 'rowFlowPosition' => 76, + 'columnFlowPosition' => 31, + ]; + yield [ + 'row' => 'H', + 'column' => 4, + 'rowFlowPosition' => 88, + 'columnFlowPosition' => 32, + ]; + yield [ + 'row' => 'A', + 'column' => 5, + 'rowFlowPosition' => 5, + 'columnFlowPosition' => 33, + ]; + yield [ + 'row' => 'B', + 'column' => 5, + 'rowFlowPosition' => 17, + 'columnFlowPosition' => 34, + ]; + yield [ + 'row' => 'C', + 'column' => 5, + 'rowFlowPosition' => 29, + 'columnFlowPosition' => 35, + ]; + yield [ + 'row' => 'D', + 'column' => 5, + 'rowFlowPosition' => 41, + 'columnFlowPosition' => 36, + ]; + yield [ + 'row' => 'E', + 'column' => 5, + 'rowFlowPosition' => 53, + 'columnFlowPosition' => 37, + ]; + yield [ + 'row' => 'F', + 'column' => 5, + 'rowFlowPosition' => 65, + 'columnFlowPosition' => 38, + ]; + yield [ + 'row' => 'G', + 'column' => 5, + 'rowFlowPosition' => 77, + 'columnFlowPosition' => 39, + ]; + yield [ + 'row' => 'H', + 'column' => 5, + 'rowFlowPosition' => 89, + 'columnFlowPosition' => 40, + ]; + yield [ + 'row' => 'A', + 'column' => 6, + 'rowFlowPosition' => 6, + 'columnFlowPosition' => 41, + ]; + yield [ + 'row' => 'B', + 'column' => 6, + 'rowFlowPosition' => 18, + 'columnFlowPosition' => 42, + ]; + yield [ + 'row' => 'C', + 'column' => 6, + 'rowFlowPosition' => 30, + 'columnFlowPosition' => 43, + ]; + yield [ + 'row' => 'D', + 'column' => 6, + 'rowFlowPosition' => 42, + 'columnFlowPosition' => 44, + ]; + yield [ + 'row' => 'E', + 'column' => 6, + 'rowFlowPosition' => 54, + 'columnFlowPosition' => 45, + ]; + yield [ + 'row' => 'F', + 'column' => 6, + 'rowFlowPosition' => 66, + 'columnFlowPosition' => 46, + ]; + yield [ + 'row' => 'G', + 'column' => 6, + 'rowFlowPosition' => 78, + 'columnFlowPosition' => 47, + ]; + yield [ + 'row' => 'H', + 'column' => 6, + 'rowFlowPosition' => 90, + 'columnFlowPosition' => 48, + ]; + yield [ + 'row' => 'A', + 'column' => 7, + 'rowFlowPosition' => 7, + 'columnFlowPosition' => 49, + ]; + yield [ + 'row' => 'B', + 'column' => 7, + 'rowFlowPosition' => 19, + 'columnFlowPosition' => 50, + ]; + yield [ + 'row' => 'C', + 'column' => 7, + 'rowFlowPosition' => 31, + 'columnFlowPosition' => 51, + ]; + yield [ + 'row' => 'D', + 'column' => 7, + 'rowFlowPosition' => 43, + 'columnFlowPosition' => 52, + ]; + yield [ + 'row' => 'E', + 'column' => 7, + 'rowFlowPosition' => 55, + 'columnFlowPosition' => 53, + ]; + yield [ + 'row' => 'F', + 'column' => 7, + 'rowFlowPosition' => 67, + 'columnFlowPosition' => 54, + ]; + yield [ + 'row' => 'G', + 'column' => 7, + 'rowFlowPosition' => 79, + 'columnFlowPosition' => 55, + ]; + yield [ + 'row' => 'H', + 'column' => 7, + 'rowFlowPosition' => 91, + 'columnFlowPosition' => 56, + ]; + yield [ + 'row' => 'A', + 'column' => 8, + 'rowFlowPosition' => 8, + 'columnFlowPosition' => 57, + ]; + yield [ + 'row' => 'B', + 'column' => 8, + 'rowFlowPosition' => 20, + 'columnFlowPosition' => 58, + ]; + yield [ + 'row' => 'C', + 'column' => 8, + 'rowFlowPosition' => 32, + 'columnFlowPosition' => 59, + ]; + yield [ + 'row' => 'D', + 'column' => 8, + 'rowFlowPosition' => 44, + 'columnFlowPosition' => 60, + ]; + yield [ + 'row' => 'E', + 'column' => 8, + 'rowFlowPosition' => 56, + 'columnFlowPosition' => 61, + ]; + yield [ + 'row' => 'F', + 'column' => 8, + 'rowFlowPosition' => 68, + 'columnFlowPosition' => 62, + ]; + yield [ + 'row' => 'G', + 'column' => 8, + 'rowFlowPosition' => 80, + 'columnFlowPosition' => 63, + ]; + yield [ + 'row' => 'H', + 'column' => 8, + 'rowFlowPosition' => 92, + 'columnFlowPosition' => 64, + ]; + yield [ + 'row' => 'A', + 'column' => 9, + 'rowFlowPosition' => 9, + 'columnFlowPosition' => 65, + ]; + yield [ + 'row' => 'B', + 'column' => 9, + 'rowFlowPosition' => 21, + 'columnFlowPosition' => 66, + ]; + yield [ + 'row' => 'C', + 'column' => 9, + 'rowFlowPosition' => 33, + 'columnFlowPosition' => 67, + ]; + yield [ + 'row' => 'D', + 'column' => 9, + 'rowFlowPosition' => 45, + 'columnFlowPosition' => 68, + ]; + yield [ + 'row' => 'E', + 'column' => 9, + 'rowFlowPosition' => 57, + 'columnFlowPosition' => 69, + ]; + yield [ + 'row' => 'F', + 'column' => 9, + 'rowFlowPosition' => 69, + 'columnFlowPosition' => 70, + ]; + yield [ + 'row' => 'G', + 'column' => 9, + 'rowFlowPosition' => 81, + 'columnFlowPosition' => 71, + ]; + yield [ + 'row' => 'H', + 'column' => 9, + 'rowFlowPosition' => 93, + 'columnFlowPosition' => 72, + ]; + yield [ + 'row' => 'A', + 'column' => 10, + 'rowFlowPosition' => 10, + 'columnFlowPosition' => 73, + ]; + yield [ + 'row' => 'B', + 'column' => 10, + 'rowFlowPosition' => 22, + 'columnFlowPosition' => 74, + ]; + yield [ + 'row' => 'C', + 'column' => 10, + 'rowFlowPosition' => 34, + 'columnFlowPosition' => 75, + ]; + yield [ + 'row' => 'D', + 'column' => 10, + 'rowFlowPosition' => 46, + 'columnFlowPosition' => 76, + ]; + yield [ + 'row' => 'E', + 'column' => 10, + 'rowFlowPosition' => 58, + 'columnFlowPosition' => 77, + ]; + yield [ + 'row' => 'F', + 'column' => 10, + 'rowFlowPosition' => 70, + 'columnFlowPosition' => 78, + ]; + yield [ + 'row' => 'G', + 'column' => 10, + 'rowFlowPosition' => 82, + 'columnFlowPosition' => 79, + ]; + yield [ + 'row' => 'H', + 'column' => 10, + 'rowFlowPosition' => 94, + 'columnFlowPosition' => 80, + ]; + yield [ + 'row' => 'A', + 'column' => 11, + 'rowFlowPosition' => 11, + 'columnFlowPosition' => 81, + ]; + yield [ + 'row' => 'B', + 'column' => 11, + 'rowFlowPosition' => 23, + 'columnFlowPosition' => 82, + ]; + yield [ + 'row' => 'C', + 'column' => 11, + 'rowFlowPosition' => 35, + 'columnFlowPosition' => 83, + ]; + yield [ + 'row' => 'D', + 'column' => 11, + 'rowFlowPosition' => 47, + 'columnFlowPosition' => 84, + ]; + yield [ + 'row' => 'E', + 'column' => 11, + 'rowFlowPosition' => 59, + 'columnFlowPosition' => 85, + ]; + yield [ + 'row' => 'F', + 'column' => 11, + 'rowFlowPosition' => 71, + 'columnFlowPosition' => 86, + ]; + yield [ + 'row' => 'G', + 'column' => 11, + 'rowFlowPosition' => 83, + 'columnFlowPosition' => 87, + ]; + yield [ + 'row' => 'H', + 'column' => 11, + 'rowFlowPosition' => 95, + 'columnFlowPosition' => 88, + ]; + yield [ + 'row' => 'A', + 'column' => 12, + 'rowFlowPosition' => 12, + 'columnFlowPosition' => 89, + ]; + yield [ + 'row' => 'B', + 'column' => 12, + 'rowFlowPosition' => 24, + 'columnFlowPosition' => 90, + ]; + yield [ + 'row' => 'C', + 'column' => 12, + 'rowFlowPosition' => 36, + 'columnFlowPosition' => 91, + ]; + yield [ + 'row' => 'D', + 'column' => 12, + 'rowFlowPosition' => 48, + 'columnFlowPosition' => 92, + ]; + yield [ + 'row' => 'E', + 'column' => 12, + 'rowFlowPosition' => 60, + 'columnFlowPosition' => 93, + ]; + yield [ + 'row' => 'F', + 'column' => 12, + 'rowFlowPosition' => 72, + 'columnFlowPosition' => 94, + ]; + yield [ + 'row' => 'G', + 'column' => 12, + 'rowFlowPosition' => 84, + 'columnFlowPosition' => 95, + ]; + yield [ + 'row' => 'H', + 'column' => 12, + 'rowFlowPosition' => 96, + 'columnFlowPosition' => 96, + ]; + } + + /** @dataProvider dataProvider12Well */ + public function testPosition12Well(string $row, int $column, int $rowFlowPosition, int $columnFlowPosition): void + { + $coordinates = new Coordinates($row, $column, new CoordinateSystem12Well()); + self::assertSame($columnFlowPosition, $coordinates->position(FlowDirection::COLUMN())); + self::assertSame($rowFlowPosition, $coordinates->position(FlowDirection::ROW())); + } + + /** @return list */ + public static function dataProvider12Well(): array + { + return [ + [ + 'row' => 'A', + 'column' => 1, + 'rowFlowPosition' => 1, + 'columnFlowPosition' => 1, + ], + [ + 'row' => 'A', + 'column' => 2, + 'rowFlowPosition' => 2, + 'columnFlowPosition' => 4, + ], + [ + 'row' => 'A', + 'column' => 3, + 'rowFlowPosition' => 3, + 'columnFlowPosition' => 7, + ], + [ + 'row' => 'A', + 'column' => 4, + 'rowFlowPosition' => 4, + 'columnFlowPosition' => 10, + ], + [ + 'row' => 'B', + 'column' => 1, + 'rowFlowPosition' => 5, + 'columnFlowPosition' => 2, + ], + [ + 'row' => 'B', + 'column' => 2, + 'rowFlowPosition' => 6, + 'columnFlowPosition' => 5, + ], + [ + 'row' => 'B', + 'column' => 3, + 'rowFlowPosition' => 7, + 'columnFlowPosition' => 8, + ], + [ + 'row' => 'B', + 'column' => 4, + 'rowFlowPosition' => 8, + 'columnFlowPosition' => 11, + ], + [ + 'row' => 'C', + 'column' => 1, + 'rowFlowPosition' => 9, + 'columnFlowPosition' => 3, + ], + [ + 'row' => 'C', + 'column' => 2, + 'rowFlowPosition' => 10, + 'columnFlowPosition' => 6, + ], + [ + 'row' => 'C', + 'column' => 3, + 'rowFlowPosition' => 11, + 'columnFlowPosition' => 9, + ], + [ + 'row' => 'C', + 'column' => 4, + 'rowFlowPosition' => 12, + 'columnFlowPosition' => 12, + ], + ]; + } + + /** @dataProvider invalidRowsOrColumns */ + public function testThrowsOnInvalidRowsOrColumns(string $row, int $column): void + { + $this->expectException(\InvalidArgumentException::class); + new Coordinates($row, $column, new CoordinateSystem96Well()); + } + + /** @return iterable */ + public static function invalidRowsOrColumns(): iterable + { + yield ['X', 2]; + yield ['B', 0]; + yield ['B', 13]; + yield ['B', -1]; + yield ['B', 1000]; + yield ['rolf', 2]; + } + + /** @dataProvider invalidPositions */ + public function testThrowsOnInvalidPositions(int $position): void + { + $this->expectException(\InvalidArgumentException::class); + Coordinates::fromPosition($position, FlowDirection::COLUMN(), new CoordinateSystem96Well()); + } + + /** @return iterable */ + public static function invalidPositions(): iterable + { + yield [0]; + yield [-1]; + yield [97]; + yield [10000]; + } + + /** @dataProvider invalidCoordinates */ + public function testThrowsOnInvalidCoordinates(string $coordinatesString): void + { + $this->expectException(\InvalidArgumentException::class); + Coordinates::fromString($coordinatesString, new CoordinateSystem96Well()); + } + + /** @return iterable */ + public static function invalidCoordinates(): iterable + { + yield ['A0']; + yield ['A001']; + yield ['X3']; + yield ['rolf']; + yield ['a1']; + } +} diff --git a/tests/Microplate/MicroplateSet/MicroplateSetABCDETest.php b/tests/Microplate/MicroplateSet/MicroplateSetABCDETest.php new file mode 100644 index 0000000..4c7bb0c --- /dev/null +++ b/tests/Microplate/MicroplateSet/MicroplateSetABCDETest.php @@ -0,0 +1,48 @@ +locationFromPosition($setPositionHigherThanMax, FlowDirection::COLUMN()); + } + + public function testSetLocationFromSetPositionFor96WellPlatesOutOfRangeTooLow(): void + { + $microplateSet = new MicroplateSetABCDE(new CoordinateSystem96Well()); + + $setPositionLowerThanMin = 0; + self::expectExceptionObject(new \OutOfRangeException("Expected a position between 1-480, got: {$setPositionLowerThanMin}")); + $microplateSet->locationFromPosition($setPositionLowerThanMin, FlowDirection::COLUMN()); + } + + public function testSetLocationFromSetPositionFor12WellPlatesOutOfRangeTooHigh(): void + { + $microplateSet = new MicroplateSetABCDE(new CoordinateSystem12Well()); + + $setPositionHigherThanMax = 61; + self::expectExceptionObject(new \OutOfRangeException("Expected a position between 1-60, got: {$setPositionHigherThanMax}")); + $microplateSet->locationFromPosition($setPositionHigherThanMax, FlowDirection::COLUMN()); + } + + public function testSetLocationFromSetPositionFor12WellPlatesOutOfRangeTooLow(): void + { + $microplateSet = new MicroplateSetABCDE(new CoordinateSystem12Well()); + + $setPositionLowerThanMin = 0; + self::expectExceptionObject(new \OutOfRangeException("Expected a position between 1-60, got: {$setPositionLowerThanMin}")); + $microplateSet->locationFromPosition($setPositionLowerThanMin, FlowDirection::COLUMN()); + } +} diff --git a/tests/Microplate/MicroplateSet/MicroplateSetABCDTest.php b/tests/Microplate/MicroplateSet/MicroplateSetABCDTest.php new file mode 100644 index 0000000..81fc89b --- /dev/null +++ b/tests/Microplate/MicroplateSet/MicroplateSetABCDTest.php @@ -0,0 +1,153 @@ +locationFromPosition($setPositionHigherThanMax, FlowDirection::COLUMN()); + } + + public function testSetLocationFromSetPositionFor96WellPlatesOutOfRangeTooLow(): void + { + $microplateSet = new MicroplateSetABCD(new CoordinateSystem96Well()); + + $setPositionLowerThanMin = 0; + self::expectExceptionObject(new \OutOfRangeException("Expected a position between 1-384, got: {$setPositionLowerThanMin}")); + $microplateSet->locationFromPosition($setPositionLowerThanMin, FlowDirection::COLUMN()); + } + + public function testSetLocationFromSetPositionFor12WellPlatesOutOfRangeTooHigh(): void + { + $microplateSet = new MicroplateSetABCD(new CoordinateSystem12Well()); + + $setPositionHigherThanMax = 49; + self::expectExceptionObject(new \OutOfRangeException("Expected a position between 1-48, got: {$setPositionHigherThanMax}")); + $microplateSet->locationFromPosition($setPositionHigherThanMax, FlowDirection::COLUMN()); + } + + public function testSetLocationFromSetPositionFor12WellPlatesOutOfRangeTooLow(): void + { + $microplateSet = new MicroplateSetABCD(new CoordinateSystem12Well()); + + $setPositionLowerThanMin = 0; + self::expectExceptionObject(new \OutOfRangeException("Expected a position between 1-48, got: {$setPositionLowerThanMin}")); + $microplateSet->locationFromPosition($setPositionLowerThanMin, FlowDirection::COLUMN()); + } + + /** @dataProvider dataProvider12Well */ + public function testSetLocationFromSetPositionFor12Wells(int $position, string $coordinatesString, string $plateID): void + { + $microplateSet = new MicroplateSetABCD(new CoordinateSystem12Well()); + + $location = $microplateSet->locationFromPosition($position, FlowDirection::COLUMN()); + self::assertSame($location->coordinates->toString(), $coordinatesString); + self::assertSame($location->plateID, $plateID); + } + + /** @return iterable */ + public static function dataProvider12Well(): iterable + { + yield [ + 'position' => 1, + 'coordinatesString' => 'A1', + 'plateID' => 'A', + ]; + yield [ + 'position' => 2, + 'coordinatesString' => 'B1', + 'plateID' => 'A', + ]; + yield [ + 'position' => 3, + 'coordinatesString' => 'C1', + 'plateID' => 'A', + ]; + yield [ + 'position' => 12, + 'coordinatesString' => 'C4', + 'plateID' => 'A', + ]; + yield [ + 'position' => 13, + 'coordinatesString' => 'A1', + 'plateID' => 'B', + ]; + yield [ + 'position' => 48, + 'coordinatesString' => 'C4', + 'plateID' => 'D', + ]; + } + + /** @dataProvider dataProvider96Well */ + public function testSetLocationFromSetPositionFor96Wells(int $position, string $coordinatesString, string $plateID): void + { + $microplateSet = new MicroplateSetABCD(new CoordinateSystem96Well()); + + $location = $microplateSet->locationFromPosition($position, FlowDirection::COLUMN()); + self::assertSame($coordinatesString, $location->coordinates->toString()); + self::assertSame($plateID, $location->plateID); + } + + /** @return iterable */ + public static function dataProvider96Well(): iterable + { + yield [ + 'position' => 1, + 'coordinatesString' => 'A1', + 'plateID' => 'A', + ]; + yield [ + 'position' => 2, + 'coordinatesString' => 'B1', + 'plateID' => 'A', + ]; + yield [ + 'position' => 3, + 'coordinatesString' => 'C1', + 'plateID' => 'A', + ]; + yield [ + 'position' => 12, + 'coordinatesString' => 'D2', + 'plateID' => 'A', + ]; + yield [ + 'position' => 13, + 'coordinatesString' => 'E2', + 'plateID' => 'A', + ]; + yield [ + 'position' => 96, + 'coordinatesString' => 'H12', + 'plateID' => 'A', + ]; + yield [ + 'position' => 97, + 'coordinatesString' => 'A1', + 'plateID' => 'B', + ]; + yield [ + 'position' => 384, + 'coordinatesString' => 'H12', + 'plateID' => 'D', + ]; + yield [ + 'position' => 383, + 'coordinatesString' => 'G12', + 'plateID' => 'D', + ]; + } +} diff --git a/tests/Microplate/MicroplateSet/MicroplateSetABTest.php b/tests/Microplate/MicroplateSet/MicroplateSetABTest.php new file mode 100644 index 0000000..6976d1a --- /dev/null +++ b/tests/Microplate/MicroplateSet/MicroplateSetABTest.php @@ -0,0 +1,159 @@ +locationFromPosition($setPositionHigherThanMax, FlowDirection::COLUMN()); + } + + public function testSetLocationFromSetPositionFor96WellPlatesOutOfRangeTooLow(): void + { + $microplateSet = new MicroplateSetAB(new CoordinateSystem96Well()); + + $setPositionLowerThanMin = 0; + self::expectExceptionObject(new \OutOfRangeException("Expected a position between 1-192, got: {$setPositionLowerThanMin}")); + $microplateSet->locationFromPosition($setPositionLowerThanMin, FlowDirection::COLUMN()); + } + + public function testSetLocationFromSetPositionFor12WellPlatesOutOfRangeTooHigh(): void + { + $microplateSet = new MicroplateSetAB(new CoordinateSystem12Well()); + + $setPositionHigherThanMax = 25; + self::expectExceptionObject(new \OutOfRangeException("Expected a position between 1-24, got: {$setPositionHigherThanMax}")); + $microplateSet->locationFromPosition($setPositionHigherThanMax, FlowDirection::COLUMN()); + } + + public function testSetLocationFromSetPositionFor12WellPlatesOutOfRangeTooLow(): void + { + $microplateSet = new MicroplateSetAB(new CoordinateSystem12Well()); + + $setPositionLowerThanMin = 0; + self::expectExceptionObject(new \OutOfRangeException("Expected a position between 1-24, got: {$setPositionLowerThanMin}")); + $microplateSet->locationFromPosition($setPositionLowerThanMin, FlowDirection::COLUMN()); + } + + /** @dataProvider dataProvider12Well */ + public function testSetLocationFromSetPositionFor12Wells(int $position, string $coordinatesString, string $plateID): void + { + $microplateSet = new MicroplateSetAB(new CoordinateSystem12Well()); + + $location = $microplateSet->locationFromPosition($position, FlowDirection::COLUMN()); + self::assertSame($location->coordinates->toString(), $coordinatesString); + self::assertSame($location->plateID, $plateID); + } + + /** @return iterable */ + public static function dataProvider12Well(): iterable + { + yield [ + 'position' => 1, + 'coordinatesString' => 'A1', + 'plateID' => 'A', + ]; + yield [ + 'position' => 2, + 'coordinatesString' => 'B1', + 'plateID' => 'A', + ]; + yield [ + 'position' => 3, + 'coordinatesString' => 'C1', + 'plateID' => 'A', + ]; + yield [ + 'position' => 12, + 'coordinatesString' => 'C4', + 'plateID' => 'A', + ]; + yield [ + 'position' => 13, + 'coordinatesString' => 'A1', + 'plateID' => 'B', + ]; + yield [ + 'position' => 24, + 'coordinatesString' => 'C4', + 'plateID' => 'B', + ]; + } + + /** @dataProvider dataProvider96Well */ + public function testSetLocationFromSetPositionFor96Wells(int $position, string $coordinatesString, string $plateID): void + { + $microplateSet = new MicroplateSetAB(new CoordinateSystem96Well()); + + $location = $microplateSet->locationFromPosition($position, FlowDirection::COLUMN()); + self::assertSame($coordinatesString, $location->coordinates->toString()); + self::assertSame($plateID, $location->plateID); + } + + /** + * @return iterable + */ + public static function dataProvider96Well(): iterable + { + yield [ + 'position' => 1, + 'coordinatesString' => 'A1', + 'plateID' => 'A', + ]; + yield [ + 'position' => 2, + 'coordinatesString' => 'B1', + 'plateID' => 'A', + ]; + yield [ + 'position' => 3, + 'coordinatesString' => 'C1', + 'plateID' => 'A', + ]; + yield [ + 'position' => 12, + 'coordinatesString' => 'D2', + 'plateID' => 'A', + ]; + yield [ + 'position' => 13, + 'coordinatesString' => 'E2', + 'plateID' => 'A', + ]; + yield [ + 'position' => 96, + 'coordinatesString' => 'H12', + 'plateID' => 'A', + ]; + yield [ + 'position' => 97, + 'coordinatesString' => 'A1', + 'plateID' => 'B', + ]; + yield [ + 'position' => 192, + 'coordinatesString' => 'H12', + 'plateID' => 'B', + ]; + yield [ + 'position' => 191, + 'coordinatesString' => 'G12', + 'plateID' => 'B', + ]; + } +} diff --git a/tests/Microplate/MicroplateSetTest.php b/tests/Microplate/MicroplateSetTest.php new file mode 100644 index 0000000..746b8ff --- /dev/null +++ b/tests/Microplate/MicroplateSetTest.php @@ -0,0 +1,21 @@ +plateCount()); + self::assertSame(MicroplateSetABCD::PLATE_COUNT, (new MicroplateSetABCD($anyCoordinateSystemWillDo))->plateCount()); + self::assertSame(MicroplateSetABCDE::PLATE_COUNT, (new MicroplateSetABCDE($anyCoordinateSystemWillDo))->plateCount()); + } +} diff --git a/tests/Microplate/MicroplateTest.php b/tests/Microplate/MicroplateTest.php new file mode 100644 index 0000000..ef4f90f --- /dev/null +++ b/tests/Microplate/MicroplateTest.php @@ -0,0 +1,273 @@ +addWell($microplateCoordinate1, $wellContent1); + + $wellContent2 = 'bar'; + $microplate->addWell($microplateCoordinate2, $wellContent2); + + self::assertEquals($wellContent1, $microplate->well($microplateCoordinate1)); + self::assertEquals($wellContent2, $microplate->well($microplateCoordinate2)); + + $coordinateWithOtherCoordinateSystem = new Coordinates('B', 2, new CoordinateSystem12Well()); + // @phpstan-ignore-next-line expecting a type error due to mismatching coordinates + $microplate->addWell($coordinateWithOtherCoordinateSystem, 'foo'); + } + + public function testMatchRow(): void + { + $coordinateSystem = new CoordinateSystem96Well(); + + $microplate = new Microplate($coordinateSystem); + + $content = 'foo'; + $coordinates = new Coordinates('A', 1, $coordinateSystem); + $key = $coordinates->toString(); + $microplate->addWell($coordinates, $content); + + $microplate->addWell(new Coordinates('B', 1, $coordinateSystem), 'bar'); + + $wells = $microplate->wells(); + + $a = $wells->filter($microplate->matchRow('A')); + self::assertCount($coordinateSystem->columnsCount(), $a); + self::assertSame($content, $a[$key]); + + $notA = $wells->reject($microplate->matchRow('A')); + self::assertCount($coordinateSystem->positionsCount() - $coordinateSystem->columnsCount(), $notA); + + $noMatch = $microplate->filledWells() + ->filter($microplate->matchRow('C')); + self::assertCount(0, $noMatch); + } + + public function testMatchColumn(): void + { + $coordinateSystem = new CoordinateSystem96Well(); + + $microplate = new Microplate($coordinateSystem); + + $coordinateColumn1 = (new Coordinates('A', 1, $coordinateSystem))->toString(); + $coordinateColumn2 = (new Coordinates('A', 2, $coordinateSystem))->toString(); + + $matchColumn = $microplate->matchColumn(1); + self::assertTrue($matchColumn('foo', $coordinateColumn1)); + self::assertFalse($matchColumn('foo', $coordinateColumn2)); + } + + public function testtoWellWithCoordinatesMapper(): void + { + $coordinateSystem = new CoordinateSystem96Well(); + $microplate = new Microplate($coordinateSystem); + + $coordinates = new Coordinates('A', 1, $coordinateSystem); + $content = 'foo'; + $microplate->addWell($coordinates, $content); + + $wellWithCoordinates = $microplate->wells() + ->map($microplate->toWellWithCoordinatesMapper()) + ->first(); + + self::assertInstanceOf(WellWithCoordinates::class, $wellWithCoordinates); + self::assertSame($content, $wellWithCoordinates->content); + self::assertEquals($coordinates, $wellWithCoordinates->coordinates); + } + + public function testSortedWells(): void + { + $microplate = $this->preparePlate(); + + /** @var Collection $keysSortedByRow PHPStan is wrong about what keys() does */ + $keysSortedByRow = $microplate->sortedWells(FlowDirection::ROW())->keys(); + self::assertSame('A1', $keysSortedByRow[0]); + self::assertSame('A2', $keysSortedByRow[1]); + self::assertSame('A3', $keysSortedByRow[2]); + self::assertSame('A4', $keysSortedByRow[3]); + self::assertSame('H11', $keysSortedByRow[94]); + self::assertSame('H12', $keysSortedByRow[95]); + + /** @var Collection $keysSortedByColumn PHPStan is wrong about what keys() does */ + $keysSortedByColumn = $microplate->sortedWells(FlowDirection::COLUMN())->keys(); + self::assertSame('A1', $keysSortedByColumn[0]); + self::assertSame('B1', $keysSortedByColumn[1]); + self::assertSame('C1', $keysSortedByColumn[2]); + self::assertSame('D1', $keysSortedByColumn[3]); + self::assertSame('G12', $keysSortedByColumn[94]); + self::assertSame('H12', $keysSortedByColumn[95]); + } + + public function testFreeWells(): void + { + $microplate = $this->preparePlate(); + + self::assertGreaterThan( + 0, + $microplate->wells() + // @phpstan-ignore-next-line generic false-positive + ->filter(static fn (?string $value): bool => $value !== null) + ->count() + ); + self::assertNotCount(0, $microplate->freeWells()); + } + + /** @phpstan-return Microplate */ + private function preparePlate(): Microplate + { + $coordinateSystem = new CoordinateSystem96Well(); + + $microplate = new Microplate($coordinateSystem); + + $data96Wells = iterator_to_array(CoordinatesTest::dataProvider96Well()); + \Safe\shuffle($data96Wells); + foreach ($data96Wells as $well) { + $microplateCoordinates = new Coordinates($well['row'], $well['column'], new CoordinateSystem96Well()); + + $randomNumber = rand(1, 100); + $randomNumberOrNull = $randomNumber > 50 ? $randomNumber : null; + + $microplate->addWell($microplateCoordinates, $randomNumberOrNull); + } + + return $microplate; + } + + public function testNextFreeWellAddingAndGetting(): void + { + $coordinateSystem = new CoordinateSystem96Well(); + $microplate = new Microplate($coordinateSystem); + + $wellData = [ + 'A1' => 'foo', + 'B1' => 'bar', + 'A2' => 'foobar', + 'A3' => 'barfoo', + ]; + + $coordinatesString1 = array_keys($wellData)[0]; + $microplateCoordinate1 = Coordinates::fromString($coordinatesString1, $coordinateSystem); + self::assertEquals($microplateCoordinate1, $microplate->nextFreeWellCoordinates(FlowDirection::COLUMN())); + $microplate->addToNextFreeWell($wellData[$coordinatesString1], FlowDirection::COLUMN()); + + $coordinatesString2 = array_keys($wellData)[1]; + $microplateCoordinate2 = Coordinates::fromString($coordinatesString2, $coordinateSystem); + self::assertEquals($microplateCoordinate2, $microplate->nextFreeWellCoordinates(FlowDirection::COLUMN())); + $microplate->addToNextFreeWell($wellData[$coordinatesString2], FlowDirection::COLUMN()); + + $microplateCoordinate3 = Coordinates::fromString('C1', $coordinateSystem); + self::assertEquals($microplateCoordinate3, $microplate->nextFreeWellCoordinates(FlowDirection::COLUMN())); + + $coordinatesString4 = array_keys($wellData)[2]; + $microplateCoordinate4 = Coordinates::fromString($coordinatesString4, $coordinateSystem); + self::assertEquals($microplateCoordinate4, $microplate->addToNextFreeWell($wellData[$coordinatesString4], FlowDirection::ROW())); + + $coordinatesString5 = array_keys($wellData)[3]; + $microplateCoordinate5 = Coordinates::fromString($coordinatesString5, $coordinateSystem); + self::assertEquals($microplateCoordinate5, $microplate->addToNextFreeWell($wellData[$coordinatesString5], FlowDirection::ROW())); + + self::assertSame($wellData, $microplate->filledWells()->toArray()); + } + + public function testThrowsPlateFullException(): void + { + $coordinateSystem = new CoordinateSystem12Well(); + $microplate = new Microplate($coordinateSystem); + + $dataProvider12Well = CoordinatesTest::dataProvider12Well(); + foreach ($dataProvider12Well as $wellData) { + $microplateCoordinates = new Coordinates($wellData['row'], $wellData['column'], $coordinateSystem); + // check that it does not throw before the plate is full + self::assertEquals($microplateCoordinates, $microplate->nextFreeWellCoordinates(FlowDirection::ROW())); + $microplate->addWell($microplateCoordinates, rand(1, 100)); + } + + $this->expectException(MicroplateIsFullException::class); + $microplate->nextFreeWellCoordinates(FlowDirection::ROW()); + } + + public function testIsConsecutiveForColumn(): void + { + $coordinateSystem = new CoordinateSystem96Well(); + $microplate = new Microplate($coordinateSystem); + + $data = [ + 'A1', 'B1', 'C1', + ]; + foreach ($data as $wellData) { + $microplateCoordinates = Coordinates::fromString($wellData, $coordinateSystem); + $microplate->addWell($microplateCoordinates, 'test'); + } + + self::assertTrue($microplate->isConsecutive(FlowDirection::COLUMN())); + self::assertFalse($microplate->isConsecutive(FlowDirection::ROW())); + + // is not consecutive anymore after adding a gap at E1 + $microplate->addWell(Coordinates::fromString('E1', $coordinateSystem), 'test'); + self::assertFalse($microplate->isConsecutive(FlowDirection::COLUMN())); + } + + public function testIsConsecutiveForRow(): void + { + $coordinateSystem = new CoordinateSystem96Well(); + $microplate = new Microplate($coordinateSystem); + + $data = [ + 'A1', 'A2', 'A3', + ]; + foreach ($data as $wellData) { + $microplateCoordinates = Coordinates::fromString($wellData, $coordinateSystem); + $microplate->addWell($microplateCoordinates, 'test'); + } + + self::assertTrue($microplate->isConsecutive(FlowDirection::ROW())); + self::assertFalse($microplate->isConsecutive(FlowDirection::COLUMN())); + + // is not consecutive anymore after adding a gap at A5 + $microplate->addWell(Coordinates::fromString('A5', $coordinateSystem), 'test'); + self::assertFalse($microplate->isConsecutive(FlowDirection::ROW())); + } + + public function testIsConsecutiveForEmptyPlate(): void + { + $coordinateSystem = new CoordinateSystem96Well(); + $microplate = new Microplate($coordinateSystem); + + self::assertFalse($microplate->isConsecutive(FlowDirection::ROW())); + self::assertFalse($microplate->isConsecutive(FlowDirection::COLUMN())); + } + + public function testIsConsecutiveForFullPlate(): void + { + $coordinateSystem = new CoordinateSystem96Well(); + $microplate = new Microplate($coordinateSystem); + + foreach ($coordinateSystem->all() as $coordinates) { + $microplate->addWell($coordinates, 'test'); + } + + self::assertTrue($microplate->isConsecutive(FlowDirection::ROW())); + self::assertTrue($microplate->isConsecutive(FlowDirection::COLUMN())); + } +} diff --git a/tests/Microplate/Scalars/Column96WellTest.php b/tests/Microplate/Scalars/Column96WellTest.php new file mode 100644 index 0000000..bd37d1a --- /dev/null +++ b/tests/Microplate/Scalars/Column96WellTest.php @@ -0,0 +1,59 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Value not in range: "12".'); + + (new Column96Well())->serialize('12'); + } + + public function testSerializeThrowsIfColumn96WellIsInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Value not in range: 13.'); + + (new Column96Well())->serialize(13); + } + + public function testSerializePassesWhenColumn96WellIsValid(): void + { + $serializedResult = (new Column96Well())->serialize(12); + + self::assertSame(12, $serializedResult); + } + + public function testParseValueThrowsIfColumn96WellIsInvalid(): void + { + $this->expectException(Error::class); + $this->expectExceptionMessage('Value not in range: 13.'); + + (new Column96Well())->parseValue(13); + } + + public function testParseValuePassesIfColumn96WellIsValid(): void + { + self::assertSame( + 12, + (new Column96Well())->parseValue(12) + ); + } +} diff --git a/tests/Microplate/Scalars/Row96WellTest.php b/tests/Microplate/Scalars/Row96WellTest.php new file mode 100644 index 0000000..610a9cd --- /dev/null +++ b/tests/Microplate/Scalars/Row96WellTest.php @@ -0,0 +1,68 @@ +expectException(InvariantViolation::class); + $this->expectExceptionMessage('The given value "I" did not match the regex /^[A-H]$/.'); + + (new Row96Well())->serialize('I'); + } + + public function testSerializeThrowsIfRow96WellIsNonCapital(): void + { + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage('The given value "h" did not match the regex /^[A-H]$/.'); + + (new Row96Well())->serialize('h'); + } + + public function testSerializePassesWhenRow96WellIsValid(): void + { + $serializedResult = (new Row96Well())->serialize('H'); + + self::assertSame('H', $serializedResult); + } + + public function testParseValueThrowsIfRow96WellIsInvalid(): void + { + $this->expectException(Error::class); + $this->expectExceptionMessage('The given value "I" did not match the regex /^[A-H]$/.'); + + (new Row96Well())->parseValue('I'); + } + + public function testParseValueThrowsIfRow96WellIsNonCapital(): void + { + $this->expectException(Error::class); + $this->expectExceptionMessage('The given value "h" did not match the regex /^[A-H]$/.'); + + (new Row96Well())->parseValue('h'); + } + + public function testParseValuePassesIfRow96WellIsValid(): void + { + self::assertSame( + 'H', + (new Row96Well())->parseValue('H') + ); + } +} diff --git a/tests/Microplate/SectionedMicroplate/FullColumnSectionTest.php b/tests/Microplate/SectionedMicroplate/FullColumnSectionTest.php new file mode 100644 index 0000000..3bf95a6 --- /dev/null +++ b/tests/Microplate/SectionedMicroplate/FullColumnSectionTest.php @@ -0,0 +1,140 @@ +sections); + + $section = $sectionedMicroplate->addSection(FullColumnSection::class); + self::assertCount(1, $sectionedMicroplate->sections); + self::assertCount(96, $sectionedMicroplate->freeWells()); + + foreach ($coordinateSystem->all() as $i => $coordinate) { + $section->addWell('column' . $i); + self::assertCount($i + 1, $sectionedMicroplate->filledWells()); + } + + self::assertCount(0, $sectionedMicroplate->freeWells()); + $this->expectExceptionObject(new MicroplateIsFullException()); + + $section->addWell('foo'); + } + + public function testCanNotAddFullColumnSectionIfAllColumnsAreReserved(): void + { + $coordinateSystem = new CoordinateSystem96Well(); + $sectionedMicroplate = new SectionedMicroplate($coordinateSystem); + + foreach (range(1, $coordinateSystem->columnsCount()) as $i) { + $sectionedMicroplate->addSection(FullColumnSection::class); + } + + $this->expectExceptionObject(new SectionIsFullException()); + $sectionedMicroplate->addSection(FullColumnSection::class); + } + + public function testCanNotGrowFullColumnSectionIfNoColumnsAreLeft(): void + { + $coordinateSystem = new CoordinateSystem96Well(); + $sectionedMicroplate = new SectionedMicroplate($coordinateSystem); + + foreach (range(1, $coordinateSystem->columnsCount() - 1) as $i) { + $sectionedMicroplate->addSection(FullColumnSection::class); + } + + $lastSection = $sectionedMicroplate->addSection(FullColumnSection::class); + foreach (range(1, $coordinateSystem->rowsCount()) as $i) { + $lastSection->addWell('foo'); + } + + $this->expectExceptionObject(new SectionIsFullException()); + $lastSection->addWell('bar'); + } + + public function testFullColumnSection(): void + { + $coordinateSystem = new CoordinateSystem96Well(); + $sectionedMicroplate = new SectionedMicroplate($coordinateSystem); + self::assertCount(0, $sectionedMicroplate->sections); + + $section1 = $sectionedMicroplate->addSection(FullColumnSection::class); + self::assertCount(1, $sectionedMicroplate->sections); + self::assertCount(96, $sectionedMicroplate->freeWells()); + + foreach (range(1, 4) as $ignored1) { + $section1->addWell('section1'); + } + + $section2 = $sectionedMicroplate->addSection(FullColumnSection::class); + $emptyCoordinateInSection1 = new Coordinates('E', 1, $coordinateSystem); + self::assertNull($sectionedMicroplate->well($emptyCoordinateInSection1)); + + foreach (range(1, 5) as $ignored1) { + $section2->addWell('section2'); + } + self::assertNull($sectionedMicroplate->well($emptyCoordinateInSection1)); + + self::assertSame([ + 'A1' => 'section1', + 'B1' => 'section1', + 'C1' => 'section1', + 'D1' => 'section1', + 'A2' => 'section2', + 'B2' => 'section2', + 'C2' => 'section2', + 'D2' => 'section2', + 'E2' => 'section2', + ], $sectionedMicroplate->filledWells()->toArray()); + + foreach (range(1, 16) as $ignored1) { + $section1->addWell('section1'); + } + + self::assertSame([ + 'A1' => 'section1', + 'B1' => 'section1', + 'C1' => 'section1', + 'D1' => 'section1', + 'E1' => 'section1', + 'F1' => 'section1', + 'G1' => 'section1', + 'H1' => 'section1', + 'A2' => 'section1', + 'B2' => 'section1', + 'C2' => 'section1', + 'D2' => 'section1', + 'E2' => 'section1', + 'F2' => 'section1', + 'G2' => 'section1', + 'H2' => 'section1', + 'A3' => 'section1', + 'B3' => 'section1', + 'C3' => 'section1', + 'D3' => 'section1', + 'A4' => 'section2', + 'B4' => 'section2', + 'C4' => 'section2', + 'D4' => 'section2', + 'E4' => 'section2', + ], $sectionedMicroplate->filledWells()->toArray()); + + $this->expectExceptionObject(new SectionIsFullException()); + + foreach (range(1, 100) as $ignored1) { + $section1->addWell('section1'); + } + } +} diff --git a/tests/Microplate/SectionedMicroplate/SectionTest.php b/tests/Microplate/SectionedMicroplate/SectionTest.php new file mode 100644 index 0000000..50d998f --- /dev/null +++ b/tests/Microplate/SectionedMicroplate/SectionTest.php @@ -0,0 +1,33 @@ +sections); + + $section = $sectionedMicroplate->addSection(Section::class); + self::assertCount(1, $sectionedMicroplate->sections); + self::assertCount(96, $sectionedMicroplate->freeWells()); + + foreach ($coordinateSystem->all() as $i => $coordinate) { + $section->addWell('column' . $i); + self::assertCount($i + 1, $sectionedMicroplate->filledWells()); + } + + self::assertCount(0, $sectionedMicroplate->freeWells()); + $this->expectExceptionObject(new MicroplateIsFullException()); + + $section->addWell('foo'); + } +} diff --git a/tests/Microplate/SectionedMicroplate/SectionedMicroplateTest.php b/tests/Microplate/SectionedMicroplate/SectionedMicroplateTest.php new file mode 100644 index 0000000..a1122cc --- /dev/null +++ b/tests/Microplate/SectionedMicroplate/SectionedMicroplateTest.php @@ -0,0 +1,48 @@ +sections); + + $section1 = $sectionedMicroplate->addSection(Section::class); + self::assertCount(1, $sectionedMicroplate->sections); + + $section2 = $sectionedMicroplate->addSection(Section::class); + self::assertCount(2, $sectionedMicroplate->sections); + + self::assertCount(0, $sectionedMicroplate->filledWells()); + self::assertCount(96, $sectionedMicroplate->freeWells()); + + $content1 = 'content1'; + $section1->addWell($content1); + $content2 = 'content2'; + $content3 = 'content3'; + $section2->addWell($content2); + $section2->addWell($content3); + + self::assertCount(3, $sectionedMicroplate->filledWells()); + self::assertCount(93, $sectionedMicroplate->freeWells()); + + self::assertSame($content1, $section1->sectionItems->first()); + + self::assertSame($content2, $section2->sectionItems->first()); + self::assertSame($content3, $section2->sectionItems->last()); + + $sectionedMicroplate->removeSection($section1); + self::assertCount(1, $sectionedMicroplate->sections); + + self::assertCount(2, $sectionedMicroplate->filledWells()); + self::assertCount(94, $sectionedMicroplate->freeWells()); + } +} diff --git a/tests/QxManager/FilledWellTest.php b/tests/QxManager/FilledWellTest.php index 05bcefa..4fabfaf 100644 --- a/tests/QxManager/FilledWellTest.php +++ b/tests/QxManager/FilledWellTest.php @@ -2,8 +2,8 @@ namespace MLL\Utils\Tests\Unit\QxManager; -use Mll\Microplate\Coordinates; -use Mll\Microplate\CoordinateSystem96Well; +use MLL\Utils\Microplate\Coordinates; +use MLL\Utils\Microplate\CoordinateSystem96Well; use MLL\Utils\QxManager\FilledRow; use MLL\Utils\QxManager\FilledWell; use PHPUnit\Framework\TestCase; diff --git a/tests/QxManager/QxManagerSampleSheetTest.php b/tests/QxManager/QxManagerSampleSheetTest.php index 635105c..b354b8b 100644 --- a/tests/QxManager/QxManagerSampleSheetTest.php +++ b/tests/QxManager/QxManagerSampleSheetTest.php @@ -3,9 +3,9 @@ namespace MLL\Utils\Tests\Unit\QxManager; use Carbon\Carbon; -use Mll\Microplate\Coordinates; -use Mll\Microplate\CoordinateSystem96Well; -use Mll\Microplate\Microplate; +use MLL\Utils\Microplate\Coordinates; +use MLL\Utils\Microplate\CoordinateSystem96Well; +use MLL\Utils\Microplate\Microplate; use MLL\Utils\QxManager\FilledRow; use MLL\Utils\QxManager\FilledWell; use MLL\Utils\QxManager\QxManagerSampleSheet;