From 8fe9a13d28967b9c89072397dd737b2fdb2b49b5 Mon Sep 17 00:00:00 2001 From: HugoFara Date: Wed, 1 Jul 2026 00:55:12 +0200 Subject: [PATCH] refactor(status): TermStatus single source of truth (#238 phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the TermStatus value object to the authoritative word-status model and remove the model's scattered re-definitions across PHP and TypeScript. Backend: - TermStatus gains abbreviation/cssClass/colourHex/order/displayName accessors, static isValid()/values()/isKnownValue()/isIgnoredValue()/isLearningValue(), and a definitions() exporter (the one ordered status table). - TermStatusService.getStatuses()/getStatusColor()/isValidStatus()/ is{Learning,Known,Ignored}Status() and StatusHelper now delegate to the VO. Scheduling members (SCORE_FORMULA_*, calculateScore, makeScoreRandomInsertUpdate) are deliberately untouched — they are Phase 2 (FSRS). - Replaced in_array($status,[1,2,3,4,5,98,99]), array_fill_keys([...]) and === 5 || === 99 / === 98 checks across Review/Vocabulary/Admin with VO calls. - New endpoint GET /api/v1/settings/status-definitions returns definitions(). Frontend: - New shared/stores/statuses.ts is the single TS status source (labels/abbr/ order/class), localized via the shared common.status_* i18n keys. - text_status_chart.ts, texts_grouped_app.ts, html_utils.ts, term_edit_modal.ts and the app_data.ts statuses proxy now resolve from it. No behaviour or wire-format change. Chart colour palettes and the popover/multi-word presentation lists are left as documented follow-ups. Tests: +TermStatus VO coverage, +status-definitions endpoint test. --- CHANGELOG.md | 14 ++ docs-src/developer/term-status-fsrs.md | 60 +++-- src/Modules/Admin/Http/AdminApiHandler.php | 6 + .../MySqlStatisticsRepository.php | 3 +- .../Application/UseCases/SubmitAnswer.php | 3 +- src/Modules/Review/Domain/ReviewWord.php | 8 +- src/Modules/Review/Http/ReviewApiHandler.php | 3 +- .../Application/Helpers/StatusHelper.php | 3 +- .../Services/TermStatusService.php | 60 ++--- .../Services/WordFamilyService.php | 15 +- .../Domain/ValueObject/TermStatus.php | 230 +++++++++++++++++- .../Vocabulary/Http/TermCrudApiHandler.php | 4 +- .../Vocabulary/Http/TermStatusApiHandler.php | 2 +- .../Vocabulary/Http/WordFamilyApiHandler.php | 5 +- .../modules/text/pages/text_status_chart.ts | 21 +- .../modules/text/pages/texts_grouped_app.ts | 11 +- .../vocabulary/components/term_edit_modal.ts | 14 +- src/frontend/js/shared/stores/app_data.ts | 48 +--- src/frontend/js/shared/stores/statuses.ts | 163 +++++++++++++ src/frontend/js/shared/utils/html_utils.ts | 25 +- .../Admin/Http/AdminApiHandlerTest.php | 23 ++ .../Domain/ValueObject/TermStatusTest.php | 154 ++++++++++++ 22 files changed, 696 insertions(+), 179 deletions(-) create mode 100644 src/frontend/js/shared/stores/statuses.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d3233a2c3..275c2a056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ ones are marked like "v1.0.0-fork". ### Changed +* **Word-status model is now a single source of truth** (#238, Phase 1): the + `TermStatus` value object is promoted to the authoritative model — it owns the + abbreviation, CSS class, light-theme colour, display order and predicates, and + exposes `TermStatus::definitions()` (the one ordered status table). The scattered + `[1,2,3,4,5,98,99]` literals and `=== 5 || === 99` / `=== 98` checks across the + Review, Vocabulary and Admin modules now call `TermStatus::isValid()` / + `values()` / `isKnownValue()` / `isIgnoredValue()`, and `TermStatusService` / + `StatusHelper` delegate to the VO instead of redefining the tables. The model is + exposed once to the frontend via `GET /api/v1/settings/status-definitions`, and + the duplicated TypeScript status tables (`text_status_chart.ts`, + `texts_grouped_app.ts`, `html_utils.ts`, `term_edit_modal.ts`, the `app_data.ts` + proxy) now resolve from a single `shared/stores/statuses.ts`. No behaviour or + wire-format change; the per-status scheduling formulas are untouched (they belong + to the proposed Phase 2, FSRS-aligned scheduling). * **Single `data_hex` word identity in the reading view** (#237): every word occurrence now carries its term identity solely as a `data_hex` attribute, and the redundant `TERM` CSS class has been dropped. The token is now a diff --git a/docs-src/developer/term-status-fsrs.md b/docs-src/developer/term-status-fsrs.md index 56fc12b2c..05c13f808 100644 --- a/docs-src/developer/term-status-fsrs.md +++ b/docs-src/developer/term-status-fsrs.md @@ -5,11 +5,14 @@ description: Centralize the scattered word-status model into a single source of # Proposal: Term Status Model + FSRS Scheduling -**Status:** Proposed — deferred until after the next release. Larger than a cleanup; -the FSRS part is an architectural change worth landing on its own. +**Status:** **Phase 1 implemented** (#238). Phase 2 (FSRS scheduling) remains +proposed — deferred, and gated on the product decisions in +[Trade-offs & open questions](#trade-offs-open-questions). The FSRS part is an +architectural change worth landing on its own. Tracked in [issue #238](https://github.com/HugoFara/lwt/issues/238). -A design proposal, not shipped work. +Phase 1 (status as a single source of truth) is shipped; the rest is a design +proposal, not shipped work. ## Problem @@ -43,18 +46,45 @@ So two distinct concerns are conflated in one integer: **how familiar a word is* 2. **Align scheduling with Anki/FSRS** by separating *display familiarity* from *memory state*, replacing the Leitner score formulas with a principled scheduler. -## Phase 1 — Status as a single source of truth - -- Promote `TermStatus` (value object) to the authoritative model: hold `value`, - `label`, `abbreviation`, `cssClass`, `colourHex`, `order`, and predicates - (`isKnown()`, `isIgnored()`, `isLearning()`). Fold `TermStatusService`'s scattered - helpers and `StatusHelper`'s magic-range arithmetic into it. -- Replace the 11+ PHP literal arrays / inline comparisons with the value object. -- Expose it **once** to the frontend (bootstrap payload or - `GET /api/v1/settings/status-definitions`); delete the ~6 duplicated TS tables in - favour of a single `shared/stores/statuses.ts`. - -This stands alone and is worth doing regardless of Phase 2. +## Phase 1 — Status as a single source of truth ✅ implemented + +This stands alone and was worth doing regardless of Phase 2. + +### What shipped + +- **`TermStatus` is now the authoritative model.** It holds the abbreviation, CSS + class, light-theme colour, order and predicates, and exposes + `TermStatus::definitions()` — the single ordered table of `value / name / abbr / + cssClass / colour / order / isKnown / isLearning / isIgnored`. `isValid()`, + `values()` and `isKnownValue()/isIgnoredValue()/isLearningValue()` (non-throwing, + safe on unvalidated input) round it out. +- **`TermStatusService` and `StatusHelper` delegate to the VO.** `getStatuses()`, + `getStatusColor()`, `isValidStatus()` and the `is{Learning,Known,Ignored}Status()` + helpers are now thin adapters; the duplicated name/abbr/colour tables are gone. + (The scheduling members — `SCORE_FORMULA_*`, `calculateScore()`, + `makeScoreRandomInsertUpdate()` — were left untouched; they are Phase 2.) +- **The scattered literals are gone.** `in_array($status, [1,2,3,4,5,98,99])`, + `array_fill_keys([1,2,3,4,5,98,99], …)` and `=== 5 || === 99` / `=== 98` checks + across the Review, Vocabulary and Admin modules now call `TermStatus::isValid()`, + `TermStatus::values()` and `isKnownValue()/isIgnoredValue()`. +- **Exposed once to the frontend** via `GET /api/v1/settings/status-definitions` + (returns `TermStatus::definitions()`). +- **One frontend store.** `shared/stores/statuses.ts` is the single TS source for + status labels/abbr/order/class (localized through the shared `common.status_*` + i18n keys, so PHP and TS resolve identical text). The duplicated `STATUS_LABELS` / + `STATUS_ORDER` tables in `text_status_chart.ts`, `texts_grouped_app.ts` and + `html_utils.ts`, the `term_edit_modal.ts` option list, and the `app_data.ts` + `statuses` proxy now all resolve from it. + +### Deliberately left for a follow-up + +- The two Chart.js **colour palettes** (`statistics_charts.ts`, + `text_status_chart.ts`) diverge from each other and from the CSS + `--lwt-status*` variables; unifying them is a *visual* change, kept out of this + cleanup. The reading view itself already single-sources its colours from CSS. +- `word_popover.ts` / `multi_word_modal.ts` keep their local status lists — those + encode popover-specific *presentation* (Bulma button colours, short `Known` / + `Ignore` badges) rather than the status model. ## Phase 2 — FSRS-aligned scheduling diff --git a/src/Modules/Admin/Http/AdminApiHandler.php b/src/Modules/Admin/Http/AdminApiHandler.php index 34b7dbb59..4242a78b6 100644 --- a/src/Modules/Admin/Http/AdminApiHandler.php +++ b/src/Modules/Admin/Http/AdminApiHandler.php @@ -22,6 +22,7 @@ use Lwt\Shared\Infrastructure\Database\Settings; use Lwt\Shared\Infrastructure\Globals; use Lwt\Modules\Admin\Domain\SettingDefinitions; +use Lwt\Modules\Vocabulary\Domain\ValueObject\TermStatus; use Lwt\Shared\Http\ApiRoutableInterface; use Lwt\Shared\Http\ApiRoutableTrait; use Lwt\Shared\Infrastructure\Http\JsonResponse; @@ -48,6 +49,11 @@ public function routeGet(array $fragments, array $params): JsonResponse if ($frag1 === 'theme-path') { return Response::success($this->formatThemePath((string) ($params['path'] ?? ''))); } + if ($frag1 === 'status-definitions') { + // Single source of truth for the word-status model, consumed by + // the frontend status store. See TermStatus::definitions(). + return Response::success(['statuses' => TermStatus::definitions()]); + } return Response::error('Endpoint Not Found: ' . $frag1, 404); } diff --git a/src/Modules/Admin/Infrastructure/MySqlStatisticsRepository.php b/src/Modules/Admin/Infrastructure/MySqlStatisticsRepository.php index c8d28b41c..15c26d210 100644 --- a/src/Modules/Admin/Infrastructure/MySqlStatisticsRepository.php +++ b/src/Modules/Admin/Infrastructure/MySqlStatisticsRepository.php @@ -18,6 +18,7 @@ namespace Lwt\Modules\Admin\Infrastructure; use Lwt\Shared\Infrastructure\Database\QueryBuilder; +use Lwt\Modules\Vocabulary\Domain\ValueObject\TermStatus; /** * MySQL repository for statistics queries. @@ -123,7 +124,7 @@ public function getTermActivityByDay(): array $changed = (int) ($record['Changed'] ?? 0); $value = (int) ($record['value'] ?? 0); - if ($status == 5 || $status == 99) { + if (TermStatus::isKnownValue($status)) { if (!isset($termKnown[$lgId][$changed])) { $termKnown[$lgId][$changed] = 0; } diff --git a/src/Modules/Review/Application/UseCases/SubmitAnswer.php b/src/Modules/Review/Application/UseCases/SubmitAnswer.php index e8cac044b..d5b05a422 100644 --- a/src/Modules/Review/Application/UseCases/SubmitAnswer.php +++ b/src/Modules/Review/Application/UseCases/SubmitAnswer.php @@ -20,6 +20,7 @@ use Lwt\Modules\Review\Domain\ReviewRepositoryInterface; use Lwt\Modules\Review\Domain\ReviewSession; use Lwt\Modules\Review\Infrastructure\SessionStateManager; +use Lwt\Modules\Vocabulary\Domain\ValueObject\TermStatus; /** * Use case for submitting an answer during review. @@ -209,7 +210,7 @@ private function calculateStatusChange(int $oldStatus, int $newStatus): int */ private function isValidStatus(int $status): bool { - return in_array($status, [1, 2, 3, 4, 5, 98, 99], true); + return TermStatus::isValid($status); } /** diff --git a/src/Modules/Review/Domain/ReviewWord.php b/src/Modules/Review/Domain/ReviewWord.php index f026d3cf6..b41ca3ae4 100644 --- a/src/Modules/Review/Domain/ReviewWord.php +++ b/src/Modules/Review/Domain/ReviewWord.php @@ -19,6 +19,8 @@ namespace Lwt\Modules\Review\Domain; +use Lwt\Modules\Vocabulary\Domain\ValueObject\TermStatus; + /** * Entity representing a word being reviewed. * @@ -135,7 +137,7 @@ public function getSentenceForDisplay(): string */ public function isLearning(): bool { - return $this->status >= 1 && $this->status <= 5; + return TermStatus::isLearningValue($this->status); } /** @@ -145,7 +147,7 @@ public function isLearning(): bool */ public function isWellKnown(): bool { - return $this->status === 99; + return $this->status === TermStatus::WELL_KNOWN; } /** @@ -155,7 +157,7 @@ public function isWellKnown(): bool */ public function isIgnored(): bool { - return $this->status === 98; + return TermStatus::isIgnoredValue($this->status); } /** diff --git a/src/Modules/Review/Http/ReviewApiHandler.php b/src/Modules/Review/Http/ReviewApiHandler.php index a0cf0fadf..b7619f95e 100644 --- a/src/Modules/Review/Http/ReviewApiHandler.php +++ b/src/Modules/Review/Http/ReviewApiHandler.php @@ -26,6 +26,7 @@ use Lwt\Shared\Infrastructure\Language\LanguagePresets; use Lwt\Modules\Vocabulary\Application\Services\ExportService; use Lwt\Modules\Vocabulary\Application\Helpers\StatusHelper; +use Lwt\Modules\Vocabulary\Domain\ValueObject\TermStatus; use Lwt\Shared\Http\ApiRoutableInterface; use Lwt\Shared\Http\ApiRoutableTrait; use Lwt\Shared\Infrastructure\Http\JsonResponse; @@ -447,7 +448,7 @@ public function updateReviewStatus(int $wordId, ?int $status, ?int $change): arr { if ($status !== null) { // Explicit status - validate it - if (!in_array($status, [1, 2, 3, 4, 5, 98, 99])) { + if (!TermStatus::isValid($status)) { return ['error' => 'Invalid status value']; } $result = $this->reviewFacade->submitAnswer($wordId, $status); diff --git a/src/Modules/Vocabulary/Application/Helpers/StatusHelper.php b/src/Modules/Vocabulary/Application/Helpers/StatusHelper.php index ee60d7d99..3d74fe847 100644 --- a/src/Modules/Vocabulary/Application/Helpers/StatusHelper.php +++ b/src/Modules/Vocabulary/Application/Helpers/StatusHelper.php @@ -20,6 +20,7 @@ use Lwt\Shared\UI\Helpers\IconHelper; use Lwt\Modules\Vocabulary\Application\Services\TermStatusService; +use Lwt\Modules\Vocabulary\Domain\ValueObject\TermStatus; /** * Helper class for word status-related display logic. @@ -111,7 +112,7 @@ public static function makeClassFilter(int|string $status): string return ''; } - $allStatuses = [1, 2, 3, 4, 5, 98, 99]; + $allStatuses = TermStatus::values(); $includedStatuses = self::getIncludedStatuses($status); // Build :not() selectors for statuses NOT in the filter diff --git a/src/Modules/Vocabulary/Application/Services/TermStatusService.php b/src/Modules/Vocabulary/Application/Services/TermStatusService.php index 9e8c4e567..35c9674d6 100644 --- a/src/Modules/Vocabulary/Application/Services/TermStatusService.php +++ b/src/Modules/Vocabulary/Application/Services/TermStatusService.php @@ -82,39 +82,24 @@ class TermStatusService /** * Return an associative array of all possible statuses. * - * Names are localized via the i18n translator (common.status_*). - * Abbreviations are language-neutral: "1".."5" for learning levels, - * empty string for 98/99 — display code should fall back to the - * full localized name when the abbreviation is empty. + * Thin adapter over {@see TermStatus::definitions()} (the single source + * of truth): names are localized via the i18n translator, abbreviations + * are language-neutral ("1".."5" for learning levels, empty for 98/99 — + * display code falls back to the full name when the abbreviation is empty). * * @return array * Keys are 1, 2, 3, 4, 5, 98, 99. */ public static function getStatuses(): array { - $learning = self::translateStatus('common.status_learning', 'Learning'); - $learned = self::translateStatus('common.status_learned', 'Learned'); - $wellKnown = self::translateStatus('common.status_well_known', 'Well Known'); - $ignored = self::translateStatus('common.status_ignored', 'Ignored'); - return [ - TermStatus::NEW => ["abbr" => "1", "name" => $learning], - TermStatus::LEARNING_2 => ["abbr" => "2", "name" => $learning], - TermStatus::LEARNING_3 => ["abbr" => "3", "name" => $learning], - TermStatus::LEARNING_4 => ["abbr" => "4", "name" => $learning], - TermStatus::LEARNED => ["abbr" => "5", "name" => $learned], - TermStatus::WELL_KNOWN => ["abbr" => "", "name" => $wellKnown], - TermStatus::IGNORED => ["abbr" => "", "name" => $ignored], - ]; - } - - /** - * Resolve a status translation key, falling back to the English label - * when the translator is unavailable (e.g. during a Container reset). - */ - private static function translateStatus(string $key, string $fallback): string - { - $value = __($key); - return ($value === $key) ? $fallback : $value; + $statuses = []; + foreach (TermStatus::definitions() as $definition) { + $statuses[$definition['value']] = [ + 'abbr' => $definition['abbr'], + 'name' => $definition['name'], + ]; + } + return $statuses; } /** @@ -147,7 +132,7 @@ public static function makeScoreRandomInsertUpdate(string $type): string */ public static function isValidStatus(int $status): bool { - return isset(self::getStatuses()[$status]); + return TermStatus::isValid($status); } /** @@ -225,16 +210,9 @@ public static function calculateTomorrowScore(int $status, int $daysSinceChange) */ public static function getStatusColor(int $status): string { - return match ($status) { - TermStatus::NEW => 'status1', - TermStatus::LEARNING_2 => 'status2', - TermStatus::LEARNING_3 => 'status3', - TermStatus::LEARNING_4 => 'status4', - TermStatus::LEARNED => 'status5', - TermStatus::IGNORED => 'status98', - TermStatus::WELL_KNOWN => 'status99', - default => 'status0', - }; + return TermStatus::isValid($status) + ? TermStatus::fromInt($status)->cssClass() + : 'status0'; } /** @@ -290,7 +268,7 @@ public static function getKnownStatuses(): array */ public static function isLearningStatus(int $status): bool { - return $status >= 1 && $status <= 5; + return TermStatus::isLearningValue($status); } /** @@ -302,7 +280,7 @@ public static function isLearningStatus(int $status): bool */ public static function isKnownStatus(int $status): bool { - return $status === TermStatus::LEARNED || $status === TermStatus::WELL_KNOWN; + return TermStatus::isKnownValue($status); } /** @@ -314,6 +292,6 @@ public static function isKnownStatus(int $status): bool */ public static function isIgnoredStatus(int $status): bool { - return $status === TermStatus::IGNORED; + return TermStatus::isIgnoredValue($status); } } diff --git a/src/Modules/Vocabulary/Application/Services/WordFamilyService.php b/src/Modules/Vocabulary/Application/Services/WordFamilyService.php index 417810a53..a0a1a47d4 100644 --- a/src/Modules/Vocabulary/Application/Services/WordFamilyService.php +++ b/src/Modules/Vocabulary/Application/Services/WordFamilyService.php @@ -20,6 +20,7 @@ namespace Lwt\Modules\Vocabulary\Application\Services; use Lwt\Modules\Vocabulary\Domain\Term; +use Lwt\Modules\Vocabulary\Domain\ValueObject\TermStatus; use Lwt\Modules\Vocabulary\Infrastructure\MySqlTermRepository; use Lwt\Shared\Infrastructure\Database\Connection; use Lwt\Shared\Infrastructure\Database\UserScopedQuery; @@ -191,7 +192,7 @@ public function getWordFamilyDetails(int $termId): ?array ); $terms = []; - $statusCounts = array_fill_keys([1, 2, 3, 4, 5, 98, 99], 0); + $statusCounts = array_fill_keys(TermStatus::values(), 0); $totalOccurrences = 0; foreach ($members as $member) { @@ -283,14 +284,14 @@ private function buildSingleTermFamily(int $termId): ?array 'stats' => [ 'formCount' => 1, 'statusCounts' => array_merge( - array_fill_keys([1, 2, 3, 4, 5, 98, 99], 0), + array_fill_keys(TermStatus::values(), 0), [$status => 1] ), 'averageStatus' => $status <= 5 ? (float) $status : 0.0, 'totalOccurrences' => $occurrences, - 'knownCount' => $status === 5 || $status === 99 ? 1 : 0, + 'knownCount' => TermStatus::isKnownValue($status) ? 1 : 0, 'learningCount' => $status >= 1 && $status <= 4 ? 1 : 0, - 'ignoredCount' => $status === 98 ? 1 : 0, + 'ignoredCount' => TermStatus::isIgnoredValue($status) ? 1 : 0, ], ]; } @@ -322,7 +323,7 @@ private function getWordOccurrenceCount(int $wordId): int */ public function updateWordFamilyStatus(int $languageId, string $lemmaLc, int $status): int { - if (!in_array($status, [1, 2, 3, 4, 5, 98, 99])) { + if (!TermStatus::isValid($status)) { return 0; } @@ -484,7 +485,7 @@ public function getSuggestedFamilyUpdate(int $termId, int $newStatus): array // Find family members with lower status (for "known" updates) // or higher status (for "learning" updates) - if ($newStatus === 99 || $newStatus === 5) { + if (TermStatus::isKnownValue($newStatus)) { // Marked as known - suggest updating forms with lower status $bindings = [$languageId, $lemmaLc, $termId]; $affected = Connection::preparedFetchAll( @@ -539,7 +540,7 @@ public function getSuggestedFamilyUpdate(int $termId, int $newStatus): array */ public function bulkUpdateTermStatus(array $termIds, int $status): int { - if (empty($termIds) || !in_array($status, [1, 2, 3, 4, 5, 98, 99])) { + if (empty($termIds) || !TermStatus::isValid($status)) { return 0; } diff --git a/src/Modules/Vocabulary/Domain/ValueObject/TermStatus.php b/src/Modules/Vocabulary/Domain/ValueObject/TermStatus.php index f8cd87b9a..fc7e3660b 100644 --- a/src/Modules/Vocabulary/Domain/ValueObject/TermStatus.php +++ b/src/Modules/Vocabulary/Domain/ValueObject/TermStatus.php @@ -54,15 +54,67 @@ /** @var int Well-known term (no need to learn) */ public const WELL_KNOWN = 99; - /** @var int[] Valid status values */ + /** @var int[] Valid status values, in canonical display order */ private const VALID_STATUSES = [ self::NEW, self::LEARNING_2, self::LEARNING_3, self::LEARNING_4, self::LEARNED, - self::IGNORED, self::WELL_KNOWN, + self::IGNORED, + ]; + + /** + * Language-neutral abbreviations. + * + * Learning levels use their digit; 98/99 have no good cross-language + * abbreviation, so the empty string signals "fall back to the full name". + * + * @var array + */ + private const ABBREVIATIONS = [ + self::NEW => '1', + self::LEARNING_2 => '2', + self::LEARNING_3 => '3', + self::LEARNING_4 => '4', + self::LEARNED => '5', + self::WELL_KNOWN => '', + self::IGNORED => '', + ]; + + /** + * CSS class names used by the reading view and status charts. + * + * @var array + */ + private const CSS_CLASSES = [ + self::NEW => 'status1', + self::LEARNING_2 => 'status2', + self::LEARNING_3 => 'status3', + self::LEARNING_4 => 'status4', + self::LEARNED => 'status5', + self::WELL_KNOWN => 'status99', + self::IGNORED => 'status98', + ]; + + /** + * Canonical light-theme colour for each status. + * + * These mirror the `--lwt-status*` CSS custom properties (the visual + * source of truth for the web UI); they are exposed here so machine + * clients (the mobile app, the REST API) have concrete values. + * + * @var array + */ + private const COLOURS = [ + self::NEW => '#E85A3C', + self::LEARNING_2 => '#E8893C', + self::LEARNING_3 => '#E8B83C', + self::LEARNING_4 => '#E8E23C', + self::LEARNED => '#66CC66', + self::WELL_KNOWN => '#CCFFCC', + self::IGNORED => '#888888', ]; /** @@ -91,6 +143,67 @@ public static function fromInt(int $status): self return new self($status); } + /** + * Check whether an integer is a valid term status. + * + * @param int $status The status value to check + * + * @return bool + */ + public static function isValid(int $status): bool + { + return in_array($status, self::VALID_STATUSES, true); + } + + /** + * All valid status values, in canonical display order + * (learning 1-5, then well-known, then ignored). + * + * @return int[] + */ + public static function values(): array + { + return self::VALID_STATUSES; + } + + /** + * Whether a raw status value represents a known term (learned or + * well-known). Returns false for invalid values rather than throwing, + * so it is safe to call on unvalidated input. + * + * @param int $status The status value + * + * @return bool + */ + public static function isKnownValue(int $status): bool + { + return $status === self::LEARNED || $status === self::WELL_KNOWN; + } + + /** + * Whether a raw status value represents an ignored term. + * + * @param int $status The status value + * + * @return bool + */ + public static function isIgnoredValue(int $status): bool + { + return $status === self::IGNORED; + } + + /** + * Whether a raw status value is a learning stage (1-5). + * + * @param int $status The status value + * + * @return bool + */ + public static function isLearningValue(int $status): bool + { + return $status >= self::NEW && $status <= self::LEARNED; + } + /** * Create a new (unknown) status. * @@ -254,6 +367,119 @@ public function label(): string }; } + /** + * Localized, user-facing status name. + * + * Learning stages 1-4 all read "Learning"; 5 is "Learned". Unlike + * {@see label()} (a fixed English developer label), this is translated + * and is what the UI and API surface to users. + * + * @return string + */ + public function displayName(): string + { + $key = match ($this->value) { + self::LEARNED => 'common.status_learned', + self::WELL_KNOWN => 'common.status_well_known', + self::IGNORED => 'common.status_ignored', + default => 'common.status_learning', + }; + return self::translate($key); + } + + /** + * Language-neutral abbreviation ('1'..'5'); empty for 98/99, where + * display code should fall back to {@see displayName()}. + * + * @return string + */ + public function abbreviation(): string + { + return self::ABBREVIATIONS[$this->value]; + } + + /** + * CSS class used by the reading view and status charts (e.g. 'status1'). + * + * @return string + */ + public function cssClass(): string + { + return self::CSS_CLASSES[$this->value]; + } + + /** + * Canonical light-theme colour hex (e.g. '#E85A3C'). Mirrors the + * `--lwt-status*` CSS variables for machine clients. + * + * @return string + */ + public function colourHex(): string + { + return self::COLOURS[$this->value]; + } + + /** + * Position in the canonical display order (1-based). + * + * @return int + */ + public function order(): int + { + return (int) array_search($this->value, self::VALID_STATUSES, true) + 1; + } + + /** + * Full machine-readable definition of every status, in display order. + * + * This is the single source of truth shared by the PHP UI, the REST API + * (`GET /api/v1/settings/status-definitions`) and — via that endpoint — + * the frontend status store. + * + * @return list + */ + public static function definitions(): array + { + $definitions = []; + foreach (self::VALID_STATUSES as $value) { + $status = new self($value); + $definitions[] = [ + 'value' => $value, + 'name' => $status->displayName(), + 'abbr' => $status->abbreviation(), + 'cssClass' => $status->cssClass(), + 'colour' => $status->colourHex(), + 'order' => $status->order(), + 'isKnown' => $status->isKnown(), + 'isLearning' => $status->isLearning(), + 'isIgnored' => $status->isIgnored(), + ]; + } + return $definitions; + } + + /** + * Resolve a translation key, falling back to a sensible English label + * when the translator is unavailable (e.g. during a Container reset). + */ + private static function translate(string $key): string + { + $value = __($key); + if ($value !== $key) { + return $value; + } + return match ($key) { + 'common.status_learned' => 'Learned', + 'common.status_well_known' => 'Well Known', + 'common.status_ignored' => 'Ignored', + default => 'Learning', + }; + } + /** * String representation. * diff --git a/src/Modules/Vocabulary/Http/TermCrudApiHandler.php b/src/Modules/Vocabulary/Http/TermCrudApiHandler.php index a53ad977a..750e6db1f 100644 --- a/src/Modules/Vocabulary/Http/TermCrudApiHandler.php +++ b/src/Modules/Vocabulary/Http/TermCrudApiHandler.php @@ -648,7 +648,7 @@ public function createTermFull(array $data): array $status = (int) ($data['status'] ?? 1); // Validate status - if (!in_array($status, [1, 2, 3, 4, 5, 98, 99])) { + if (!TermStatusService::isValidStatus($status)) { return ['error' => 'Status must be 1-5, 98, or 99']; } @@ -767,7 +767,7 @@ public function updateTermFull(int $termId, array $data): array $status = (int) ($data['status'] ?? $existing['WoStatus']); // Validate status - if (!in_array($status, [1, 2, 3, 4, 5, 98, 99])) { + if (!TermStatusService::isValidStatus($status)) { return ['error' => 'Status must be 1-5, 98, or 99']; } diff --git a/src/Modules/Vocabulary/Http/TermStatusApiHandler.php b/src/Modules/Vocabulary/Http/TermStatusApiHandler.php index 246985324..8628fad55 100644 --- a/src/Modules/Vocabulary/Http/TermStatusApiHandler.php +++ b/src/Modules/Vocabulary/Http/TermStatusApiHandler.php @@ -261,7 +261,7 @@ public function getNewStatus(int $oldstatus, bool $up): int */ public function updateWordStatus(int $wid, int $currstatus): ?string { - if (($currstatus >= 1 && $currstatus <= 5) || $currstatus == 99 || $currstatus == 98) { + if (TermStatusService::isValidStatus($currstatus)) { $m1 = $this->setWordStatus($wid, $currstatus); if ($m1 == 1) { /** @var int|null $fetchedStatus */ diff --git a/src/Modules/Vocabulary/Http/WordFamilyApiHandler.php b/src/Modules/Vocabulary/Http/WordFamilyApiHandler.php index 347385c04..bd66957ce 100644 --- a/src/Modules/Vocabulary/Http/WordFamilyApiHandler.php +++ b/src/Modules/Vocabulary/Http/WordFamilyApiHandler.php @@ -21,6 +21,7 @@ use Lwt\Api\V1\Response; use Lwt\Modules\Vocabulary\Application\Services\LemmaService; +use Lwt\Modules\Vocabulary\Domain\ValueObject\TermStatus; use Lwt\Shared\Http\ApiRoutableInterface; use Lwt\Shared\Http\ApiRoutableTrait; use Lwt\Shared\Infrastructure\Http\JsonResponse; @@ -179,7 +180,7 @@ public function getWordFamilyListFromParams(int $langId, array $params = []): ar */ public function updateWordFamilyStatus(int $langId, string $lemmaLc, int $status): array { - if (!in_array($status, [1, 2, 3, 4, 5, 98, 99])) { + if (!TermStatus::isValid($status)) { return ['success' => false, 'error' => 'Invalid status']; } @@ -211,7 +212,7 @@ public function getFamilyUpdateSuggestion(int $termId, int $newStatus): array */ public function applyFamilyUpdate(array $termIds, int $status): array { - if (!in_array($status, [1, 2, 3, 4, 5, 98, 99])) { + if (!TermStatus::isValid($status)) { return ['success' => false, 'count' => 0]; } diff --git a/src/frontend/js/modules/text/pages/text_status_chart.ts b/src/frontend/js/modules/text/pages/text_status_chart.ts index 209e0d5ef..96a3706af 100644 --- a/src/frontend/js/modules/text/pages/text_status_chart.ts +++ b/src/frontend/js/modules/text/pages/text_status_chart.ts @@ -13,6 +13,7 @@ import { onDomReady } from '@shared/utils/dom_ready'; import type { Chart as ChartType } from 'chart.js'; import { loadChartJs } from '@shared/utils/chart_loader'; +import { STATUS_DISPLAY_ORDER, statusLabel } from '@shared/stores/statuses'; /** * Status colors matching LWT's existing barchart CSS styles. @@ -28,20 +29,6 @@ const STATUS_COLORS: Record = { 99: '#999' // Well Known - dark gray }; -/** - * Status labels for tooltips. - */ -const STATUS_LABELS: Record = { - 0: 'Unknown', - 1: 'Learning (1)', - 2: 'Learning (2)', - 3: 'Learning (3)', - 4: 'Learning (4)', - 5: 'Learned (5)', - 98: 'Ignored', - 99: 'Well Known' -}; - /** * Status descriptions for tooltips. */ @@ -57,9 +44,9 @@ const STATUS_DESCRIPTIONS: Record = { }; /** - * Status order for chart segments. + * Status order for chart segments (Unknown, Learning 1-5, Well Known, Ignored). */ -const STATUS_ORDER = [0, 1, 2, 3, 4, 5, 99, 98]; +const STATUS_ORDER = STATUS_DISPLAY_ORDER; /** * Map of text ID to Chart instance for cleanup/updates. @@ -120,7 +107,7 @@ export async function createTextStatusChart( // Create datasets for each status const datasets = STATUS_ORDER.map(status => ({ - label: STATUS_LABELS[status], + label: statusLabel(status), data: [statusData[status] || 0], backgroundColor: STATUS_COLORS[status], borderWidth: 0, diff --git a/src/frontend/js/modules/text/pages/texts_grouped_app.ts b/src/frontend/js/modules/text/pages/texts_grouped_app.ts index d6c26aa5a..7b49f1b0b 100644 --- a/src/frontend/js/modules/text/pages/texts_grouped_app.ts +++ b/src/frontend/js/modules/text/pages/texts_grouped_app.ts @@ -16,6 +16,7 @@ import { initIcons } from '@shared/icons/lucide_icons'; import { apiGet, getCsrfToken } from '@shared/api/client'; import { TextsApi } from '@modules/text/api/texts_api'; import { confirmDelete } from '@shared/utils/ui_utilities'; +import { STATUS_DISPLAY_ORDER, statusLabel } from '@shared/stores/statuses'; /** * Text item from API. @@ -393,24 +394,18 @@ export function textsGroupedData(): TextsGroupedData { return []; } - const STATUS_LABELS: Record = { - 0: 'Unknown', 1: 'Learning (1)', 2: 'Learning (2)', - 3: 'Learning (3)', 4: 'Learning (4)', 5: 'Learned (5)', - 98: 'Ignored', 99: 'Well Known' - }; - const STATUS_ORDER = [0, 1, 2, 3, 4, 5, 99, 98]; const { total, unknown, statusCounts } = stats; const segments: Array<{status: number, percent: string, label: string, count: number}> = []; - for (const status of STATUS_ORDER) { + for (const status of STATUS_DISPLAY_ORDER) { const count = status === 0 ? unknown : (statusCounts[String(status)] || 0); if (count > 0) { const pct = (count / total) * 100; segments.push({ status, percent: pct.toFixed(2) + '%', - label: `${STATUS_LABELS[status]}: ${count} (${pct.toFixed(1)}%)`, + label: `${statusLabel(status)}: ${count} (${pct.toFixed(1)}%)`, count }); } diff --git a/src/frontend/js/modules/vocabulary/components/term_edit_modal.ts b/src/frontend/js/modules/vocabulary/components/term_edit_modal.ts index 050c97740..a4b9cf259 100644 --- a/src/frontend/js/modules/vocabulary/components/term_edit_modal.ts +++ b/src/frontend/js/modules/vocabulary/components/term_edit_modal.ts @@ -16,17 +16,7 @@ import { type TermUpdateFullRequest } from '@modules/vocabulary/api/terms_api'; import { escapeHtml } from '@shared/utils/html_utils'; - -/** Status definitions for the form */ -const STATUSES = [ - { value: 1, label: 'Learning (1)' }, - { value: 2, label: 'Learning (2)' }, - { value: 3, label: 'Learning (3)' }, - { value: 4, label: 'Learning (4)' }, - { value: 5, label: 'Learned' }, - { value: 99, label: 'Well Known' }, - { value: 98, label: 'Ignored' } -]; +import { getStatusDefinitions } from '@shared/stores/statuses'; /** Current form context */ let currentContext: { @@ -45,7 +35,7 @@ function renderForm(data: TermForEditResponse): string { const lang = data.language; const translation = term.translation === '*' ? '' : term.translation; - const statusOptions = STATUSES.map(s => + const statusOptions = getStatusDefinitions().map(s => `` ).join(''); diff --git a/src/frontend/js/shared/stores/app_data.ts b/src/frontend/js/shared/stores/app_data.ts index d7d588303..c89efa357 100644 --- a/src/frontend/js/shared/stores/app_data.ts +++ b/src/frontend/js/shared/stores/app_data.ts @@ -4,56 +4,16 @@ * This module provides centralized access to application data that was * previously passed via inline PHP scripts (STATUSES, TAGS, TEXTTAGS). * - * - STATUSES: Static data hardcoded here (never changes) + * - STATUSES: re-exported from the canonical status store (`./statuses`) * - TAGS/TEXTTAGS: Fetched from API on demand with caching * * @license Unlicense * @since 3.0.0 */ -import type { WordStatus } from '@/types/globals'; -import { t } from '@shared/i18n/translator'; - -/** - * Word statuses — localized labels. - * - * Numeric statuses use language-neutral digit abbreviations ("1".."5"). - * For 98/99 there is no good cross-language abbreviation, so the localized - * full name doubles as both `name` and `abbr`. - * - * This is a getter (Proxy) rather than a static object so the translator - * has time to initialize before the strings are read. - */ -export const statuses: Record = new Proxy({} as Record, { - get(_target, prop: string | symbol): WordStatus | undefined { - const key = Number(prop); - if (Number.isNaN(key)) return undefined; - const learning = t('common.status_learning'); - const learned = t('common.status_learned'); - const wellKnown = t('common.status_well_known'); - const ignored = t('common.status_ignored'); - switch (key) { - case 1: return { abbr: '1', name: learning }; - case 2: return { abbr: '2', name: learning }; - case 3: return { abbr: '3', name: learning }; - case 4: return { abbr: '4', name: learning }; - case 5: return { abbr: '5', name: learned }; - case 99: return { abbr: wellKnown, name: wellKnown }; - case 98: return { abbr: ignored, name: ignored }; - default: return undefined; - } - }, - ownKeys(): string[] { - return ['1', '2', '3', '4', '5', '98', '99']; - }, - getOwnPropertyDescriptor(): PropertyDescriptor { - return { enumerable: true, configurable: true }; - }, - has(_target, prop: string | symbol): boolean { - const key = Number(prop); - return [1, 2, 3, 4, 5, 98, 99].includes(key); - } -}); +// Word statuses now live in the canonical status store; re-exported here so +// existing `from '@shared/stores/app_data'` imports keep working. +export { statuses } from './statuses'; // Cache for tags data let termTagsCache: string[] | null = null; diff --git a/src/frontend/js/shared/stores/statuses.ts b/src/frontend/js/shared/stores/statuses.ts new file mode 100644 index 000000000..13db2878b --- /dev/null +++ b/src/frontend/js/shared/stores/statuses.ts @@ -0,0 +1,163 @@ +/** + * Status store — the single frontend source of truth for the word-status model. + * + * Word status is an integer: 1-5 (learning stages), 98 (ignored), 99 (well-known), + * plus the frontend-only pseudo-status 0 (a word not yet saved to the vocabulary). + * + * The structural model here (values, order, abbreviations, CSS classes, predicates) + * mirrors the backend value object `TermStatus` (PHP), which serves the same data via + * `GET /api/v1/settings/status-definitions`. Labels are localized through the shared + * `common.status_*` i18n keys, so PHP and TS resolve identical text from one source. + * + * Before this store, label/abbr/order/class tables were re-defined across half a dozen + * components; consume the helpers here instead of hardcoding new ones. + * + * @license Unlicense + * @since 3.2.2 + */ + +import type { WordStatus } from '@/types/globals'; +import { t } from '@shared/i18n/translator'; + +/** + * Canonical display order, including the pseudo-status 0 (Unknown / not yet saved). + * Well-known (99) precedes ignored (98), matching the reading view and charts. + */ +export const STATUS_DISPLAY_ORDER: readonly number[] = [0, 1, 2, 3, 4, 5, 99, 98]; + +/** + * Real, saveable term statuses (excludes the 0 pseudo-status), in canonical order. + */ +export const TERM_STATUS_VALUES: readonly number[] = [1, 2, 3, 4, 5, 99, 98]; + +/** A status and its display metadata. */ +export interface StatusDefinition { + value: number; + /** Localized base name, e.g. "Learning", "Learned". */ + name: string; + /** Localized label with a level suffix for learning stages, e.g. "Learning (1)". */ + label: string; + /** Language-neutral abbreviation ('1'..'5'); empty for 0/98/99 (use the name). */ + abbr: string; + /** Reading-view / chart CSS class, e.g. "status1". */ + cssClass: string; + /** Position in {@link STATUS_DISPLAY_ORDER} (0-based). */ + order: number; +} + +/** + * Localized base name for a status (no level suffix). + * + * @param value Status value (0, 1-5, 98, 99) + */ +export function statusName(value: number): string { + switch (value) { + case 0: return t('common.status_unknown'); + case 5: return t('common.status_learned'); + case 98: return t('common.status_ignored'); + case 99: return t('common.status_well_known'); + default: return t('common.status_learning'); // learning stages 1-4 + } +} + +/** + * Localized display label: learning stages 1-5 get a level suffix + * ("Learning (1)" … "Learned (5)"); others use the bare name. + * + * @param value Status value (0, 1-5, 98, 99) + */ +export function statusLabel(value: number): string { + if (value >= 1 && value <= 5) { + return `${statusName(value)} (${value})`; + } + return statusName(value); +} + +/** + * Language-neutral abbreviation: '1'..'5' for learning stages, '' otherwise + * (display code should fall back to {@link statusName}). + * + * @param value Status value + */ +export function statusAbbr(value: number): string { + return value >= 1 && value <= 5 ? String(value) : ''; +} + +/** + * CSS class for a status (e.g. "status1", "status99", "status0" for unknown). + * + * @param value Status value + */ +export function statusCssClass(value: number): string { + return `status${value}`; +} + +/** Whether a status counts as "known" (learned or well-known). */ +export function isKnownStatus(value: number): boolean { + return value === 5 || value === 99; +} + +/** Whether a status is a learning stage (1-5). */ +export function isLearningStatus(value: number): boolean { + return value >= 1 && value <= 5; +} + +/** Whether a status is the ignored flag (98). */ +export function isIgnoredStatus(value: number): boolean { + return value === 98; +} + +/** + * Full metadata for a single status. + * + * @param value Status value + */ +export function getStatusDefinition(value: number): StatusDefinition { + return { + value, + name: statusName(value), + label: statusLabel(value), + abbr: statusAbbr(value), + cssClass: statusCssClass(value), + order: STATUS_DISPLAY_ORDER.indexOf(value), + }; +} + +/** + * Definitions for every real term status (1-5, 99, 98) in canonical order. + * Pass `includeUnknown` to prepend the 0 pseudo-status (for charts). + * + * @param includeUnknown Include the Unknown (0) pseudo-status + */ +export function getStatusDefinitions(includeUnknown = false): StatusDefinition[] { + const order = includeUnknown ? STATUS_DISPLAY_ORDER : TERM_STATUS_VALUES; + return order.map(getStatusDefinition); +} + +/** + * Word statuses — localized labels, keyed by status value. + * + * Numeric statuses use digit abbreviations ("1".."5"); for 98/99 there is no good + * cross-language abbreviation, so the localized full name doubles as `name` and `abbr`. + * + * A getter (Proxy) rather than a static object, so the translator has time to + * initialize before the strings are read. + */ +export const statuses: Record = new Proxy({} as Record, { + get(_target, prop: string | symbol): WordStatus | undefined { + const key = Number(prop); + if (Number.isNaN(key) || !TERM_STATUS_VALUES.includes(key)) return undefined; + const name = statusName(key); + const abbr = statusAbbr(key); + return { abbr: abbr === '' ? name : abbr, name }; + }, + ownKeys(): string[] { + return TERM_STATUS_VALUES.map(String); + }, + getOwnPropertyDescriptor(): PropertyDescriptor { + return { enumerable: true, configurable: true }; + }, + has(_target, prop: string | symbol): boolean { + return TERM_STATUS_VALUES.includes(Number(prop)); + } +}); diff --git a/src/frontend/js/shared/utils/html_utils.ts b/src/frontend/js/shared/utils/html_utils.ts index 1d33ffc2e..54ff691fd 100644 --- a/src/frontend/js/shared/utils/html_utils.ts +++ b/src/frontend/js/shared/utils/html_utils.ts @@ -6,6 +6,8 @@ * @since 2.10.0-fork Extracted from pgm.ts */ +import { STATUS_DISPLAY_ORDER, statusLabel } from '@shared/stores/statuses'; + /** * Replace html characters with encodings * @@ -75,25 +77,6 @@ export function renderTags(tagList: string): string { .join(''); } -/** - * Status labels for tooltips. - */ -const STATUS_LABELS: Record = { - 0: 'Unknown', - 1: 'Learning (1)', - 2: 'Learning (2)', - 3: 'Learning (3)', - 4: 'Learning (4)', - 5: 'Learned (5)', - 98: 'Ignored', - 99: 'Well Known' -}; - -/** - * Order of statuses in the chart (left to right). - */ -const STATUS_ORDER = [0, 1, 2, 3, 4, 5, 99, 98]; - /** * Statistics data from the API. */ @@ -121,7 +104,7 @@ export function renderStatusBarChart(stats: StatsData | null | undefined): strin // Build segments for each status const segments: string[] = []; - for (const status of STATUS_ORDER) { + for (const status of STATUS_DISPLAY_ORDER) { let count: number; if (status === 0) { count = unknown; @@ -131,7 +114,7 @@ export function renderStatusBarChart(stats: StatsData | null | undefined): strin if (count > 0) { const percent = (count / total) * 100; - const label = STATUS_LABELS[status]; + const label = statusLabel(status); segments.push( `
assertStringContainsString('nonexistent', $data['error']); } + #[Test] + public function routeGetStatusDefinitionsReturnsAllStatuses(): void + { + $response = $this->handler->routeGet( + ['settings', 'status-definitions'], + [] + ); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame(200, $response->getStatusCode()); + + $data = $response->getData(); + $this->assertIsArray($data); + $this->assertArrayHasKey('statuses', $data); + $this->assertCount(7, $data['statuses']); + + $first = $data['statuses'][0]; + foreach (['value', 'name', 'abbr', 'cssClass', 'colour', 'order'] as $key) { + $this->assertArrayHasKey($key, $first); + } + $this->assertSame([1, 2, 3, 4, 5, 99, 98], array_column($data['statuses'], 'value')); + } + // ========================================================================= // routePost tests // ========================================================================= diff --git a/tests/backend/Modules/Vocabulary/Domain/ValueObject/TermStatusTest.php b/tests/backend/Modules/Vocabulary/Domain/ValueObject/TermStatusTest.php index 0a641a9b4..f280416fa 100644 --- a/tests/backend/Modules/Vocabulary/Domain/ValueObject/TermStatusTest.php +++ b/tests/backend/Modules/Vocabulary/Domain/ValueObject/TermStatusTest.php @@ -404,4 +404,158 @@ public function testDecreaseDoesNotMutateOriginal(): void $original->decrease(); $this->assertEquals(3, $original->toInt()); } + + // ===== Static helper Tests ===== + + #[DataProvider('validStatusProvider')] + public function testIsValidReturnsTrueForValidStatuses(int $value): void + { + $this->assertTrue(TermStatus::isValid($value)); + } + + #[DataProvider('invalidStatusProvider')] + public function testIsValidReturnsFalseForInvalidStatuses(int $value): void + { + $this->assertFalse(TermStatus::isValid($value)); + } + + public function testValuesReturnsAllSevenInCanonicalOrder(): void + { + $this->assertSame([1, 2, 3, 4, 5, 99, 98], TermStatus::values()); + } + + public function testIsKnownValue(): void + { + $this->assertTrue(TermStatus::isKnownValue(5)); + $this->assertTrue(TermStatus::isKnownValue(99)); + $this->assertFalse(TermStatus::isKnownValue(1)); + $this->assertFalse(TermStatus::isKnownValue(98)); + // Safe on invalid input (does not throw) + $this->assertFalse(TermStatus::isKnownValue(0)); + $this->assertFalse(TermStatus::isKnownValue(42)); + } + + public function testIsIgnoredValue(): void + { + $this->assertTrue(TermStatus::isIgnoredValue(98)); + $this->assertFalse(TermStatus::isIgnoredValue(99)); + $this->assertFalse(TermStatus::isIgnoredValue(5)); + $this->assertFalse(TermStatus::isIgnoredValue(0)); + } + + public function testIsLearningValue(): void + { + foreach ([1, 2, 3, 4, 5] as $value) { + $this->assertTrue(TermStatus::isLearningValue($value)); + } + $this->assertFalse(TermStatus::isLearningValue(98)); + $this->assertFalse(TermStatus::isLearningValue(99)); + $this->assertFalse(TermStatus::isLearningValue(0)); + $this->assertFalse(TermStatus::isLearningValue(6)); + } + + // ===== Display-metadata Tests ===== + + #[DataProvider('abbreviationProvider')] + public function testAbbreviation(int $value, string $expected): void + { + $this->assertSame($expected, TermStatus::fromInt($value)->abbreviation()); + } + + public static function abbreviationProvider(): array + { + return [ + [1, '1'], [2, '2'], [3, '3'], [4, '4'], [5, '5'], + // 98/99 fall back to the full name in display code. + [98, ''], [99, ''], + ]; + } + + #[DataProvider('cssClassProvider')] + public function testCssClass(int $value, string $expected): void + { + $this->assertSame($expected, TermStatus::fromInt($value)->cssClass()); + } + + public static function cssClassProvider(): array + { + return [ + [1, 'status1'], [2, 'status2'], [3, 'status3'], [4, 'status4'], + [5, 'status5'], [98, 'status98'], [99, 'status99'], + ]; + } + + #[DataProvider('validStatusProvider')] + public function testColourHexIsValidHex(int $value): void + { + $this->assertMatchesRegularExpression( + '/^#[0-9A-Fa-f]{6}$/', + TermStatus::fromInt($value)->colourHex() + ); + } + + public function testOrderFollowsCanonicalSequence(): void + { + $this->assertSame(1, TermStatus::fromInt(1)->order()); + $this->assertSame(5, TermStatus::fromInt(5)->order()); + // Well-known precedes ignored in display order. + $this->assertSame(6, TermStatus::fromInt(99)->order()); + $this->assertSame(7, TermStatus::fromInt(98)->order()); + } + + #[DataProvider('displayNameProvider')] + public function testDisplayName(int $value, string $expected): void + { + $this->assertSame($expected, TermStatus::fromInt($value)->displayName()); + } + + public static function displayNameProvider(): array + { + // Localized names; learning stages 1-4 all read "Learning". + return [ + [1, 'Learning'], [2, 'Learning'], [3, 'Learning'], [4, 'Learning'], + [5, 'Learned'], [98, 'Ignored'], [99, 'Well Known'], + ]; + } + + // ===== definitions() Tests ===== + + public function testDefinitionsReturnsAllSevenInOrder(): void + { + $definitions = TermStatus::definitions(); + $this->assertCount(7, $definitions); + $this->assertSame( + [1, 2, 3, 4, 5, 99, 98], + array_column($definitions, 'value') + ); + } + + public function testDefinitionsEntriesHaveCompleteShape(): void + { + foreach (TermStatus::definitions() as $definition) { + foreach ( + ['value', 'name', 'abbr', 'cssClass', 'colour', + 'order', 'isKnown', 'isLearning', 'isIgnored'] as $key + ) { + $this->assertArrayHasKey($key, $definition); + } + $this->assertIsString($definition['name']); + $this->assertMatchesRegularExpression('/^status\d+$/', $definition['cssClass']); + $this->assertMatchesRegularExpression('/^#[0-9A-Fa-f]{6}$/', $definition['colour']); + } + } + + public function testDefinitionsClassificationFlagsAreConsistent(): void + { + $byValue = []; + foreach (TermStatus::definitions() as $definition) { + $byValue[$definition['value']] = $definition; + } + $this->assertTrue($byValue[5]['isKnown']); + $this->assertTrue($byValue[99]['isKnown']); + $this->assertFalse($byValue[1]['isKnown']); + $this->assertTrue($byValue[98]['isIgnored']); + $this->assertTrue($byValue[1]['isLearning']); + $this->assertFalse($byValue[98]['isLearning']); + } }