Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
10 / 10
CRAP
100.00% covered (success)
100.00%
1 / 1
CategorySetting
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
10 / 10
14
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 for
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 change
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 category
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 label
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 icon
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 color
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isActive
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizeLabel
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 normalize
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace App\Expense\Domain;
6
7use Doctrine\ORM\Mapping as ORM;
8use InvalidArgumentException;
9
10/**
11 * Admin overlay on top of a Category: which categories are active, and how they
12 * present. Every override is nullable and falls back to the enum's own default,
13 * so a category with no row at all behaves exactly as the code says and stays
14 * active. The enum keeps owning the value set — this only curates it.
15 *
16 * ponytail: overrides, not a row per category. Adding a case to the enum needs
17 * no migration and no seed; the table only ever holds what an admin changed.
18 */
19#[ORM\Entity]
20#[ORM\Table(name: 'category_settings')]
21final class CategorySetting
22{
23    private const MAX_LABEL = 40;
24    // PrimeIcons class pair, e.g. "pi pi-shopping-cart" — the UI drops it into a
25    // class attribute, so only that shape is accepted.
26    private const ICON_PATTERN = '/^pi pi-[a-z0-9-]+$/';
27    private const COLOR_PATTERN = '/^#[0-9a-fA-F]{6}$/';
28
29    private function __construct(
30        #[ORM\Id]
31        #[ORM\Column(type: 'category', length: 20)]
32        private readonly Category $category,
33        #[ORM\Column(nullable: true, length: self::MAX_LABEL)]
34        private ?string $label,
35        #[ORM\Column(nullable: true, length: 40)]
36        private ?string $icon,
37        #[ORM\Column(nullable: true, length: 7)]
38        private ?string $color,
39        #[ORM\Column]
40        private bool $active,
41    ) {
42    }
43
44    /** A fresh overlay: nothing overridden, active — i.e. the code defaults. */
45    public static function for(Category $category): self
46    {
47        return new self($category, null, null, null, true);
48    }
49
50    /** Null or empty for any of label/icon/color resets it to the enum default. */
51    public function change(?string $label, ?string $icon, ?string $color, bool $active): void
52    {
53        // Validate everything before touching the entity: a rejected field must not
54        // leave a half-applied overlay behind for the next flush to pick up.
55        $label = self::normalizeLabel($label);
56        $icon = self::normalize($icon, self::ICON_PATTERN, 'icon (expected "pi pi-<name>")');
57        $color = self::normalize($color, self::COLOR_PATTERN, 'colour (expected "#rrggbb")');
58
59        $this->label = $label;
60        $this->icon = $icon;
61        $this->color = $color;
62        $this->active = $active;
63    }
64
65    public function category(): Category
66    {
67        return $this->category;
68    }
69
70    public function label(): string
71    {
72        return $this->label ?? $this->category->label();
73    }
74
75    public function icon(): string
76    {
77        return $this->icon ?? $this->category->icon();
78    }
79
80    public function color(): string
81    {
82        return $this->color ?? $this->category->color();
83    }
84
85    public function isActive(): bool
86    {
87        return $this->active;
88    }
89
90    private static function normalizeLabel(?string $label): ?string
91    {
92        $label = trim((string) $label);
93        if ($label === '') {
94            return null;
95        }
96        if (mb_strlen($label) > self::MAX_LABEL) {
97            throw new InvalidArgumentException(sprintf('Category label cannot exceed %d characters.', self::MAX_LABEL));
98        }
99
100        return $label;
101    }
102
103    private static function normalize(?string $value, string $pattern, string $what): ?string
104    {
105        $value = trim((string) $value);
106        if ($value === '') {
107            return null;
108        }
109        if (preg_match($pattern, $value) !== 1) {
110            throw new InvalidArgumentException(sprintf('Invalid category %s.', $what));
111        }
112
113        return $value;
114    }
115}