28 KiB
Machine Context Custom Fields — Backend Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. Parallel plan: This is the backend half. The frontend plan is at2026-04-02-machine-context-fields-frontend.md. Both can run in parallel on separate worktrees — they share no files.
Goal: Add machineContextOnly flag on CustomField, link FKs on CustomFieldValue, structure controller normalization, upsert support, clone support, and tests.
Architecture: CustomField.machineContextOnly flags definitions. CustomFieldValue gets nullable FKs to MachineComponentLink/MachinePieceLink. The structure response returns contextCustomFields and contextCustomFieldValues per link. Upsert and clone are extended.
Tech Stack: Symfony 8, Doctrine ORM, API Platform 4, PostgreSQL 16, PHPUnit 12
Spec: docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md
File Map
Create
migrations/VersionXXXX_MachineContextCustomFields.php— migrationtests/Api/Entity/MachineContextCustomFieldTest.php— test class
Modify
src/Entity/CustomField.php— addmachineContextOnlypropertysrc/Entity/CustomFieldValue.php— addmachineComponentLinkandmachinePieceLinkFKssrc/Entity/MachineComponentLink.php— addcontextFieldValuescollectionsrc/Entity/MachinePieceLink.php— addcontextFieldValuescollectionsrc/Controller/MachineStructureController.php— normalize context fields + clonesrc/Controller/CustomFieldValueController.php— link-based upserttests/AbstractApiTestCase.php— extend factories
Task 1: Migration + Entity CustomField — machineContextOnly
Files:
-
Modify:
src/Entity/CustomField.php(add property after line 56) -
Step 1: Add
machineContextOnlyproperty toCustomFieldentity
In src/Entity/CustomField.php, add after the $required property (line 56):
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false], name: 'machinecontextonly')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private bool $machineContextOnly = false;
Add getter/setter before the closing }:
public function isMachineContextOnly(): bool
{
return $this->machineContextOnly;
}
public function setMachineContextOnly(bool $machineContextOnly): static
{
$this->machineContextOnly = $machineContextOnly;
return $this;
}
- Step 2: Generate and adjust migration
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff
Edit the generated migration to use idempotent SQL:
ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machinecontextonly BOOLEAN DEFAULT false NOT NULL;
- Step 3: Run migration
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
- Step 4: Run linter
make php-cs-fixer-allow-risky
- Step 5: Commit
git add src/Entity/CustomField.php migrations/
git commit -m "feat(custom-fields) : add machineContextOnly flag to CustomField entity"
Task 2: Entity CustomFieldValue — link FKs + inverse collections
Files:
-
Modify:
src/Entity/CustomFieldValue.php(add after line 67 —$productproperty) -
Modify:
src/Entity/MachineComponentLink.php(add after line 72 —$productLinks) -
Modify:
src/Entity/MachinePieceLink.php(add after line 61 —$productLinks) -
Step 1: Add FKs to
CustomFieldValue
In src/Entity/CustomFieldValue.php, add after the $product property (line 67):
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'contextFieldValues')]
#[ORM\JoinColumn(name: 'machinecomponentlinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachineComponentLink $machineComponentLink = null;
#[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'contextFieldValues')]
#[ORM\JoinColumn(name: 'machinepiecelinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachinePieceLink $machinePieceLink = null;
Add getters/setters before the closing }:
public function getMachineComponentLink(): ?MachineComponentLink
{
return $this->machineComponentLink;
}
public function setMachineComponentLink(?MachineComponentLink $machineComponentLink): static
{
$this->machineComponentLink = $machineComponentLink;
return $this;
}
public function getMachinePieceLink(): ?MachinePieceLink
{
return $this->machinePieceLink;
}
public function setMachinePieceLink(?MachinePieceLink $machinePieceLink): static
{
$this->machinePieceLink = $machinePieceLink;
return $this;
}
- Step 2: Add
contextFieldValuescollection toMachineComponentLink
In src/Entity/MachineComponentLink.php, add after the $productLinks collection (line 72):
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'machineComponentLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contextFieldValues;
In the constructor (line 95), add:
$this->contextFieldValues = new ArrayCollection();
Add getter before the closing }:
/**
* @return Collection<int, CustomFieldValue>
*/
public function getContextFieldValues(): Collection
{
return $this->contextFieldValues;
}
- Step 3: Add
contextFieldValuescollection toMachinePieceLink
In src/Entity/MachinePieceLink.php, add after the $productLinks collection (line 61):
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'machinePieceLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contextFieldValues;
In the constructor (line 86), add:
$this->contextFieldValues = new ArrayCollection();
Add getter:
/**
* @return Collection<int, CustomFieldValue>
*/
public function getContextFieldValues(): Collection
{
return $this->contextFieldValues;
}
- Step 4: Generate and adjust migration
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff
Edit migration to use idempotent SQL:
ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinecomponentlinkid VARCHAR(36) DEFAULT NULL;
ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinepiecelinkid VARCHAR(36) DEFAULT NULL;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_component_link') THEN
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_component_link
FOREIGN KEY (machinecomponentlinkid) REFERENCES machine_component_links(id) ON DELETE CASCADE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_piece_link') THEN
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_piece_link
FOREIGN KEY (machinepiecelinkid) REFERENCES machine_piece_links(id) ON DELETE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_cfv_machine_component_link ON custom_field_values(machinecomponentlinkid);
CREATE INDEX IF NOT EXISTS idx_cfv_machine_piece_link ON custom_field_values(machinepiecelinkid);
- Step 5: Run migration + linter
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
make php-cs-fixer-allow-risky
- Step 6: Commit
git add src/Entity/CustomFieldValue.php src/Entity/MachineComponentLink.php src/Entity/MachinePieceLink.php migrations/
git commit -m "feat(custom-fields) : add link FKs to CustomFieldValue for machine context"
Task 3: Test factories — extend helpers
Files:
-
Modify:
tests/AbstractApiTestCase.php:399-461 -
Step 1: Update
createCustomField()factory
In tests/AbstractApiTestCase.php, add bool $machineContextOnly = false parameter at the end of createCustomField (line 399), and add $cf->setMachineContextOnly($machineContextOnly); after $cf->setOrderIndex($orderIndex); (line 411).
- Step 2: Update
createCustomFieldValue()factory
Add two new nullable parameters at the end of createCustomFieldValue (line 432):
?MachineComponentLink $machineComponentLink = null,
?MachinePieceLink $machinePieceLink = null,
Add the corresponding setter calls after the closing } of the if (null !== $product) block (after line 454, NOT inside it):
if (null !== $machineComponentLink) {
$cfv->setMachineComponentLink($machineComponentLink);
}
if (null !== $machinePieceLink) {
$cfv->setMachinePieceLink($machinePieceLink);
}
- Step 3: Run linter
make php-cs-fixer-allow-risky
- Step 4: Commit
git add tests/AbstractApiTestCase.php
git commit -m "test(custom-fields) : extend factories for machineContextOnly and link params"
Task 4: MachineStructureController — normalize context fields
Files:
-
Modify:
src/Controller/MachineStructureController.php -
Step 1: Add
machineContextOnlyto all normalization methods
In normalizeCustomFields (line 601), add to the output array at line 615:
'machineContextOnly' => $customField->isMachineContextOnly(),
In normalizeCustomFieldDefinitions (line 838), add to the output array at line 852:
'machineContextOnly' => $cf->isMachineContextOnly(),
In normalizeCustomFieldValues (line 861), add to the nested customField array at line 879:
'machineContextOnly' => $cf->isMachineContextOnly(),
- Step 2: Add
normalizeContextCustomFieldDefinitionshelper
Add a new private method after normalizeCustomFieldValues:
private function normalizeContextCustomFieldDefinitions(Collection $customFields): array
{
$items = [];
foreach ($customFields as $cf) {
if (!$cf instanceof CustomField || !$cf->isMachineContextOnly()) {
continue;
}
$items[] = [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
'machineContextOnly' => true,
];
}
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
return $items;
}
- Step 3: Update
normalizeComponentLinksto include context fields
In normalizeComponentLinks (line 622), add $type variable and context field keys to the returned array:
private function normalizeComponentLinks(array $links): array
{
return array_map(function (MachineComponentLink $link): array {
$composant = $link->getComposant();
$parentLink = $link->getParentLink();
$type = $composant->getTypeComposant();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'composantId' => $composant->getId(),
'composant' => $this->normalizeComposant($composant),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'childLinks' => [],
'pieceLinks' => [],
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getComponentCustomFields()) : [],
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
];
}, $links);
}
- Step 4: Update
normalizePieceLinksto include context fields
In normalizePieceLinks (line 644):
private function normalizePieceLinks(array $links): array
{
return array_map(function (MachinePieceLink $link): array {
$piece = $link->getPiece();
$parentLink = $link->getParentLink();
$type = $piece->getTypePiece();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'pieceId' => $piece->getId(),
'piece' => $this->normalizePiece($piece),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'quantity' => $this->resolvePieceQuantity($link),
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getPieceCustomFields()) : [],
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
];
}, $links);
}
- Step 5: Run linter
make php-cs-fixer-allow-risky
- Step 6: Commit
git add src/Controller/MachineStructureController.php
git commit -m "feat(custom-fields) : expose context custom fields in machine structure response"
Task 5: CustomFieldValueController — support link-based upsert
Files:
-
Modify:
src/Controller/CustomFieldValueController.php -
Step 1: Inject link repositories in constructor
In the constructor (line 24), add:
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
Add use statements at the top:
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
- Step 2: Extend
resolveTargetto support link entities
In resolveTarget (line 211), the method applies strtolower() on entityType at line 213. The candidate loop and match block must handle this.
Update the candidate list in the foreach (line 217):
foreach (['machine', 'composant', 'piece', 'product', 'machineComponentLink', 'machinePieceLink'] as $candidate) {
IMPORTANT: The candidate loop assigns $entityType in camelCase, but strtolower() (line 213) only applies when entityType comes from the payload directly. Add a normalization after the loop closes (after the existing break; at line 226), before the empty-check at line 229:
$entityType = strtolower($entityType);
This ensures both code paths (direct payload entityType and candidate loop) deliver lowercase to the match block.
Update the match block (line 233) — all keys lowercase, but resolveEntity returns camelCase type for applyTarget:
return match ($entityType) {
'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository),
'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository),
'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
'product' => $this->resolveEntity('product', $entityId, $this->productRepository),
'machinecomponentlink' => $this->resolveEntity('machineComponentLink', $entityId, $this->machineComponentLinkRepository),
'machinepiecelink' => $this->resolveEntity('machinePieceLink', $entityId, $this->machinePieceLinkRepository),
default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
};
- Step 3: Extend
applyTargetfor link entities
Add two new cases in applyTarget (line 252):
case 'machineComponentLink':
$value->setMachineComponentLink($entity);
$value->setComposant($entity->getComposant());
break;
case 'machinePieceLink':
$value->setMachinePieceLink($entity);
$value->setPiece($entity->getPiece());
break;
- Step 4: Run linter
make php-cs-fixer-allow-risky
- Step 5: Commit
git add src/Controller/CustomFieldValueController.php
git commit -m "feat(custom-fields) : support link-based upsert in CustomFieldValueController"
Task 6: Clone support — copy context field values
Files:
-
Modify:
src/Controller/MachineStructureController.php(aftercloneProductLinks, beforeflushincloneMachine) -
Step 1: Add
cloneContextFieldValueshelper method
Add after cloneProductLinks:
/**
* @param array<string, MachineComponentLink> $componentLinkMap
* @param array<string, MachinePieceLink> $pieceLinkMap
*/
private function cloneContextFieldValues(
array $componentLinkMap,
array $pieceLinkMap,
): void {
foreach ($componentLinkMap as $oldLinkId => $newLink) {
$oldLink = $this->machineComponentLinkRepository->find($oldLinkId);
if (!$oldLink) {
continue;
}
foreach ($oldLink->getContextFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$newValue->setMachineComponentLink($newLink);
$newValue->setComposant($newLink->getComposant());
$this->entityManager->persist($newValue);
}
}
foreach ($pieceLinkMap as $oldLinkId => $newLink) {
$oldLink = $this->machinePieceLinkRepository->find($oldLinkId);
if (!$oldLink) {
continue;
}
foreach ($oldLink->getContextFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$newValue->setMachinePieceLink($newLink);
$newValue->setPiece($newLink->getPiece());
$this->entityManager->persist($newValue);
}
}
}
- Step 2: Call from
cloneMachinemethod
In cloneMachine (line 113), after $this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap); (line 163) and before $this->entityManager->flush(); (line 165), add:
$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap);
- Step 3: Run linter
make php-cs-fixer-allow-risky
- Step 4: Commit
git add src/Controller/MachineStructureController.php
git commit -m "feat(custom-fields) : clone context field values on machine clone"
Task 7: Backend tests
Files:
-
Create:
tests/Api/Entity/MachineContextCustomFieldTest.php -
Step 1: Write test class
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
class MachineContextCustomFieldTest extends AbstractApiTestCase
{
public function testStructureReturnsContextFieldsOnComponentLink(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site A');
$modelType = $this->createModelType('Motor', 'MOT', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Voltage',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$normalField = $this->createCustomField(
name: 'Serial',
type: 'text',
typeComposant: $modelType,
);
$machine = $this->createMachine('Machine A', $site);
$composant = $this->createComposant('Motor 1', 'MOT-001', $modelType);
$link = $this->createMachineComponentLink($machine, $composant);
$this->createCustomFieldValue(
customField: $contextField,
value: '220',
composant: $composant,
machineComponentLink: $link,
);
$response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure');
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$componentLink = $data['componentLinks'][0];
$this->assertArrayHasKey('contextCustomFields', $componentLink);
$this->assertCount(1, $componentLink['contextCustomFields']);
$this->assertSame('Voltage', $componentLink['contextCustomFields'][0]['name']);
$this->assertTrue($componentLink['contextCustomFields'][0]['machineContextOnly']);
$this->assertArrayHasKey('contextCustomFieldValues', $componentLink);
$this->assertCount(1, $componentLink['contextCustomFieldValues']);
$this->assertSame('220', $componentLink['contextCustomFieldValues'][0]['value']);
$normalFields = array_filter(
$componentLink['composant']['customFields'],
fn (array $f) => $f['name'] === 'Serial',
);
$this->assertCount(1, $normalFields);
}
public function testStructureReturnsContextFieldsOnPieceLink(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site B');
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
$contextField = $this->createCustomField(
name: 'Wear Level',
type: 'select',
typePiece: $modelType,
machineContextOnly: true,
);
$contextField->setOptions(['Good', 'Fair', 'Replace']);
$this->getEntityManager()->flush();
$machine = $this->createMachine('Machine B', $site);
$piece = $this->createPiece('Bearing 1', 'BRG-001', $modelType);
$link = $this->createMachinePieceLink($machine, $piece);
$this->createCustomFieldValue(
customField: $contextField,
value: 'Fair',
piece: $piece,
machinePieceLink: $link,
);
$response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure');
$data = $response->toArray();
$pieceLink = $data['pieceLinks'][0];
$this->assertCount(1, $pieceLink['contextCustomFields']);
$this->assertSame('Wear Level', $pieceLink['contextCustomFields'][0]['name']);
$this->assertCount(1, $pieceLink['contextCustomFieldValues']);
$this->assertSame('Fair', $pieceLink['contextCustomFieldValues'][0]['value']);
}
public function testUpsertContextFieldValueViaComponentLink(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site C');
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Flow Rate',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$machine = $this->createMachine('Machine C', $site);
$composant = $this->createComposant('Pump 1', 'PMP-001', $modelType);
$link = $this->createMachineComponentLink($machine, $composant);
$response = $client->request('POST', '/api/custom-fields/values/upsert', [
'json' => [
'customFieldId' => $contextField->getId(),
'machineComponentLinkId' => $link->getId(),
'value' => '380',
],
]);
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertSame('380', $data['value']);
}
public function testSameComposantDifferentValuesPerMachine(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site D');
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Pressure',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$machineA = $this->createMachine('Machine A', $site);
$machineB = $this->createMachine('Machine B', $site);
$composant = $this->createComposant('Valve 1', 'VLV-001', $modelType);
$linkA = $this->createMachineComponentLink($machineA, $composant);
$linkB = $this->createMachineComponentLink($machineB, $composant);
$this->createCustomFieldValue(
customField: $contextField,
value: '100',
composant: $composant,
machineComponentLink: $linkA,
);
$this->createCustomFieldValue(
customField: $contextField,
value: '200',
composant: $composant,
machineComponentLink: $linkB,
);
$dataA = $client->request('GET', '/api/machines/'.$machineA->getId().'/structure')->toArray();
$this->assertSame('100', $dataA['componentLinks'][0]['contextCustomFieldValues'][0]['value']);
$dataB = $client->request('GET', '/api/machines/'.$machineB->getId().'/structure')->toArray();
$this->assertSame('200', $dataB['componentLinks'][0]['contextCustomFieldValues'][0]['value']);
}
public function testMachineContextOnlyFieldSerialization(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site E');
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Calibration Date',
type: 'date',
typeComposant: $modelType,
machineContextOnly: true,
);
$response = $client->request('GET', '/api/custom_fields/'.$contextField->getId());
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertTrue($data['machineContextOnly']);
}
public function testCloneMachineCopiesContextFieldValues(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site F');
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'RPM Setting',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$source = $this->createMachine('Source Machine', $site);
$composant = $this->createComposant('Motor C', 'MOTC-001', $modelType);
$link = $this->createMachineComponentLink($source, $composant);
$this->createCustomFieldValue(
customField: $contextField,
value: '3000',
composant: $composant,
machineComponentLink: $link,
);
$response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
'json' => [
'name' => 'Cloned Machine',
'siteId' => $site->getId(),
],
]);
$this->assertResponseStatusCodeSame(201);
$data = $response->toArray();
$clonedLink = $data['componentLinks'][0] ?? null;
$this->assertNotNull($clonedLink);
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
$this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']);
}
}
- Step 2: Run tests
make test FILES=tests/Api/Entity/MachineContextCustomFieldTest.php
Expected: All 6 tests pass.
- Step 3: Run linter
make php-cs-fixer-allow-risky
- Step 4: Run full test suite
make test
Expected: All existing tests still pass.
- Step 5: Commit
git add tests/Api/Entity/MachineContextCustomFieldTest.php
git commit -m "test(custom-fields) : add tests for machine context custom fields"