diff --git a/extensions/subscriptions/extend.php b/extensions/subscriptions/extend.php index f563b6b81f..fb852cf47f 100644 --- a/extensions/subscriptions/extend.php +++ b/extensions/subscriptions/extend.php @@ -20,6 +20,7 @@ use Flarum\Post\Event\Restored; use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\Subscriptions\Api\UserResourceFields; +use Flarum\Subscriptions\Extend\Subscription; use Flarum\Subscriptions\Filter\SubscriptionFilter; use Flarum\Subscriptions\HideIgnoredFromAllDiscussionsPage; use Flarum\Subscriptions\Listener; @@ -64,6 +65,12 @@ ->listen(Posted::class, Listener\FollowAfterReply::class) ->listen(Started::class, Listener\FollowAfterCreate::class), + // Register the built-in subscription types. Third-party extensions can add + // their own types via (new Flarum\Subscriptions\Extend\Subscription())->addSubscriptionType(). + (new Subscription()) + ->addSubscriptionType('follow', ['follow', 'following', 'followed']) + ->addSubscriptionType('ignore', ['ignore', 'ignoring', 'ignored']), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) ->addFilter(DiscussionSearcher::class, SubscriptionFilter::class) ->addMutator(DiscussionSearcher::class, HideIgnoredFromAllDiscussionsPage::class), diff --git a/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts b/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts index 4fd449a7c5..0bccb27dd6 100644 --- a/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts +++ b/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts @@ -9,11 +9,26 @@ export default class SubscriptionGambit extends BooleanGambit { ]; } + canonicalKey(): string[] { + return ['following', 'ignoring']; + } + toFilter(matches: string[], negate: boolean): Record { - const key = (negate ? '-' : '') + this.filterKey(); + const filterKey = (negate ? '-' : '') + this.filterKey(); + + // Map the matched surface keyword to the canonical internal DB value. + // This ensures SubscriptionFilter always receives 'follow' or 'ignore' + // regardless of which locale keyword was used. + const allFollowKeys = [ + 'following', + 'followed', + app.translator.trans('flarum-subscriptions.lib.gambits.discussions.subscription.following_key', {}, true), + ]; + + const value = allFollowKeys.includes(matches[1]) ? 'follow' : 'ignore'; return { - [key]: matches[1], + [filterKey]: value, }; } @@ -22,7 +37,12 @@ export default class SubscriptionGambit extends BooleanGambit { } fromFilter(value: string, negate: boolean): string { - return `${negate ? '-' : ''}is:${value}`; + const key = this.key(); + // value is the canonical DB value ('follow' or 'ignore'); map back to + // the locale-appropriate surface keyword for display in the search bar. + const keyword = value === 'follow' ? key[0] : key[1]; + + return `${negate ? '-' : ''}is:${keyword}`; } enabled(): boolean { diff --git a/extensions/subscriptions/src/Extend/Subscription.php b/extensions/subscriptions/src/Extend/Subscription.php new file mode 100644 index 0000000000..27810cc7d8 --- /dev/null +++ b/extensions/subscriptions/src/Extend/Subscription.php @@ -0,0 +1,61 @@ +addSubscriptionType('lurk', ['lurk', 'lurking', 'lurked']) + * ``` + * + * @param string $canonicalValue The value stored in the database. + * @param string|string[] $aliases One or more filter values that map to this canonical value. + */ + public function addSubscriptionType(string $canonicalValue, string|array $aliases): self + { + $this->types[$canonicalValue] = array_merge( + $this->types[$canonicalValue] ?? [], + (array) $aliases, + ); + + return $this; + } + + public function extend(Container $container, ?Extension $extension = null): void + { + // Ensure the registry exists before extending it. + $container->bindIf('flarum-subscriptions.subscription_types', fn () => []); + + $container->extend('flarum-subscriptions.subscription_types', function (array $types) { + foreach ($this->types as $canonical => $aliases) { + $types[$canonical] = array_unique(array_merge($types[$canonical] ?? [], $aliases)); + } + + return $types; + }); + } +} diff --git a/extensions/subscriptions/src/Filter/SubscriptionFilter.php b/extensions/subscriptions/src/Filter/SubscriptionFilter.php index 993949ebd7..c10ad56b1e 100644 --- a/extensions/subscriptions/src/Filter/SubscriptionFilter.php +++ b/extensions/subscriptions/src/Filter/SubscriptionFilter.php @@ -14,6 +14,7 @@ use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; use Flarum\User\User; +use Illuminate\Contracts\Container\Container; use Illuminate\Database\Eloquent\Builder; /** @@ -23,6 +24,11 @@ class SubscriptionFilter implements FilterInterface { use ValidateFilterTrait; + public function __construct( + private readonly Container $container, + ) { + } + public function getFilterKey(): string { return 'subscription'; @@ -32,9 +38,34 @@ public function filter(SearchState $state, string|array $value, bool $negate): v { $value = $this->asString($value); - preg_match('/^(follow|ignor)(?:ing|ed)$/i', $value, $matches); + $canonicalValue = $this->resolveCanonicalValue($value); + + if ($canonicalValue === null) { + // Unrecognised value — match nothing rather than everything. + $state->getQuery()->whereRaw('0 = 1'); + + return; + } + + $this->constrain($state->getQuery(), $state->getActor(), $canonicalValue, $negate); + } + + /** + * Resolve a filter value to its canonical database value using the + * registered subscription type registry. Returns null if unrecognised. + */ + protected function resolveCanonicalValue(string $value): ?string + { + /** @var array $types */ + $types = $this->container->make('flarum-subscriptions.subscription_types'); + + foreach ($types as $canonical => $aliases) { + if (in_array($value, $aliases, true)) { + return $canonical; + } + } - $this->constrain($state->getQuery(), $state->getActor(), $matches[1], $negate); + return null; } protected function constrain(Builder $query, User $actor, string $subscriptionType, bool $negate): void @@ -42,9 +73,9 @@ protected function constrain(Builder $query, User $actor, string $subscriptionTy $method = $negate ? 'whereNotIn' : 'whereIn'; $query->$method('discussions.id', function ($query) use ($actor, $subscriptionType) { $query->select('discussion_id') - ->from('discussion_user') - ->where('user_id', $actor->id) - ->where('subscription', $subscriptionType === 'follow' ? 'follow' : 'ignore'); + ->from('discussion_user') + ->where('user_id', $actor->id) + ->where('subscription', $subscriptionType); }); } } diff --git a/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php b/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php new file mode 100644 index 0000000000..0ea3cb7d7b --- /dev/null +++ b/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php @@ -0,0 +1,391 @@ +extension('flarum-subscriptions'); + + $this->prepareDatabase([ + User::class => [ + $this->normalUser(), + ['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1], + ], + Discussion::class => [ + ['id' => 1, 'title' => 'Followed by normal', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], + ['id' => 2, 'title' => 'Ignored by normal', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 2, 'comment_count' => 1], + ['id' => 3, 'title' => 'No subscription', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 3, 'comment_count' => 1], + ['id' => 10, 'title' => 'Also followed by acme', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 10, 'comment_count' => 1], + ], + Post::class => [ + ['id' => 1, 'number' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo

'], + ['id' => 2, 'number' => 1, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo

'], + ['id' => 3, 'number' => 1, 'discussion_id' => 3, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo

'], + ['id' => 10, 'number' => 1, 'discussion_id' => 10, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo

'], + ], + 'discussion_user' => [ + ['discussion_id' => 1, 'user_id' => 2, 'last_read_post_number' => 1, 'subscription' => 'follow'], + ['discussion_id' => 2, 'user_id' => 2, 'last_read_post_number' => 1, 'subscription' => 'ignore'], + // User 3 (acme) follows discussion 10 — kept separate from user 2's subscriptions + // so the negation tests (which assert exact ID sets for user 2) are not affected. + ['discussion_id' => 10, 'user_id' => 3, 'last_read_post_number' => 1, 'subscription' => 'follow'], + ], + ]); + } + + // ------------------------------------------------------------------------- + // filter[subscription]=following + // ------------------------------------------------------------------------- + + #[Test] + public function subscription_filter_following_returns_only_followed_discussions(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => 'following']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEqualsCanonicalizing(['1'], $ids); + } + + #[Test] + public function subscription_filter_following_returns_nothing_for_guest(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions') + ->withQueryParams(['filter' => ['subscription' => 'following']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEmpty($ids); + } + + // ------------------------------------------------------------------------- + // filter[subscription]=ignoring + // ------------------------------------------------------------------------- + + #[Test] + public function subscription_filter_ignoring_returns_only_ignored_discussions(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => 'ignoring']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEqualsCanonicalizing(['2'], $ids); + } + + // ------------------------------------------------------------------------- + // Negation + // ------------------------------------------------------------------------- + + #[Test] + public function subscription_filter_following_negated_excludes_followed_discussions(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['-subscription' => 'following']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + // The followed discussion must be excluded; everything else is included. + $this->assertNotContains('1', $ids); + $this->assertContains('2', $ids); + $this->assertContains('3', $ids); + } + + #[Test] + public function subscription_filter_ignoring_negated_excludes_ignored_discussions(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['-subscription' => 'ignoring']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + // The ignored discussion must be excluded; everything else is included. + $this->assertNotContains('2', $ids); + $this->assertContains('1', $ids); + $this->assertContains('3', $ids); + } + + // ------------------------------------------------------------------------- + // Value variants accepted by SubscriptionFilter's regex + // ------------------------------------------------------------------------- + + public static function followVariantsProvider(): array + { + return [ + 'following' => ['following'], + 'followed' => ['followed'], + ]; + } + + #[Test] + #[DataProvider('followVariantsProvider')] + public function subscription_filter_accepts_follow_variants(string $value): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => $value]]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEqualsCanonicalizing(['1'], $ids, "Value '{$value}' should match followed discussions"); + } + + public static function ignoreVariantsProvider(): array + { + return [ + 'ignoring' => ['ignoring'], + 'ignored' => ['ignored'], + ]; + } + + #[Test] + #[DataProvider('ignoreVariantsProvider')] + public function subscription_filter_accepts_ignore_variants(string $value): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => $value]]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEqualsCanonicalizing(['2'], $ids, "Value '{$value}' should match ignored discussions"); + } + + // ------------------------------------------------------------------------- + // Unrecognised value is silently ignored (no crash, empty result) + // ------------------------------------------------------------------------- + + #[Test] + public function subscription_filter_with_unrecognised_value_returns_empty_not_crash(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => '跟随']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEmpty($ids, 'An unrecognised subscription value should return no results, not crash'); + } + + // ========================================================================= + // TDD: Intended behaviour after the localization alias fix + // + // The JS GambitManager will always send the canonical internal value + // ('follow' or 'ignore') as the filter value — never the surface keyword. + // SubscriptionFilter must therefore accept these canonical values directly. + // Unrecognised values must return 200 with empty results, not 500. + // + // All tests in this section will FAIL until SubscriptionFilter is updated. + // ========================================================================= + + // ------------------------------------------------------------------------- + // Canonical internal values ('follow' / 'ignore') accepted directly + // + // After the fix, SubscriptionGambit.toFilter() will pass the canonical + // internal value rather than the surface keyword, so SubscriptionFilter + // must recognise these. + // ------------------------------------------------------------------------- + + #[Test] + public function subscription_filter_accepts_canonical_follow_value(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => 'follow']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEqualsCanonicalizing(['1'], $ids, "'follow' (canonical) should match followed discussions"); + } + + #[Test] + public function subscription_filter_accepts_canonical_ignore_value(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => 'ignore']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEqualsCanonicalizing(['2'], $ids, "'ignore' (canonical) should match ignored discussions"); + } + + #[Test] + public function subscription_filter_canonical_follow_negated(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['-subscription' => 'follow']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertNotContains('1', $ids); + $this->assertContains('2', $ids); + $this->assertContains('3', $ids); + } + + #[Test] + public function subscription_filter_canonical_ignore_negated(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['-subscription' => 'ignore']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertNotContains('2', $ids); + $this->assertContains('1', $ids); + $this->assertContains('3', $ids); + } + + // ------------------------------------------------------------------------- + // Unrecognised value: 200 + empty (no crash) + // + // Once the null-check is added, any value that doesn't match a known + // subscription type must be silently ignored and return no results. + // ------------------------------------------------------------------------- + + #[Test] + public function subscription_filter_with_unrecognised_value_returns_empty(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => '跟随']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEmpty($ids, 'An unrecognised subscription value should return no results, not crash'); + } + + #[Test] + public function subscription_filter_with_empty_value_returns_empty(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => '']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEmpty($ids, 'An empty subscription value should return no results, not crash'); + } + + // ------------------------------------------------------------------------- + // Subscription extender — third-party type registration + // ------------------------------------------------------------------------- + + #[Test] + public function subscription_extender_registers_additional_alias_for_existing_type(): void + { + // Register a third-party alias ('subscribed') that maps to the built-in 'follow' canonical. + // User 3 (acme) follows discussion 10 — seeded in setUp(). + $this->extend( + (new Subscription()) + ->addSubscriptionType('follow', ['subscribed']) + ); + + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 3]) + ->withQueryParams(['filter' => ['subscription' => 'subscribed']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertContains('10', $ids, "'subscribed' alias should resolve to 'follow' and match discussion 10"); + } + + #[Test] + public function subscription_extender_unregistered_alias_returns_empty(): void + { + // Without registering 'subscribed', it should return no results. + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 3]) + ->withQueryParams(['filter' => ['subscription' => 'subscribed']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEmpty($ids, 'Unregistered alias should return no results'); + } +} diff --git a/framework/core/js/jest.config.cjs b/framework/core/js/jest.config.cjs index a2d3d0f14b..8b4a4bbdaf 100644 --- a/framework/core/js/jest.config.cjs +++ b/framework/core/js/jest.config.cjs @@ -1 +1,8 @@ -module.exports = require('@flarum/jest-config')({}); +module.exports = require('@flarum/jest-config')({ + moduleNameMapper: { + // webpack expose-loader syntax is meaningless in Jest — map to empty stubs + '^expose-loader.*$': '/tests/__mocks__/emptyModule.mjs', + // nanoid is ESM-only; stub it with a simple ESM id generator for tests + '^nanoid$': '/tests/__mocks__/nanoid.mjs', + }, +}); diff --git a/framework/core/js/src/common/GambitManager.ts b/framework/core/js/src/common/GambitManager.ts index 2a6051c0ca..4f11bf774d 100644 --- a/framework/core/js/src/common/GambitManager.ts +++ b/framework/core/js/src/common/GambitManager.ts @@ -48,6 +48,13 @@ export default class GambitManager { const pattern = new RegExp(`^(-?)${gambit.pattern()}$`, 'i'); let matches = bit.match(pattern); + // If the translated pattern didn't match, try the canonical English + // pattern as an alias — so English always works regardless of locale. + if (!matches && gambit.canonicalPattern() !== gambit.pattern()) { + const canonicalPattern = new RegExp(`^(-?)${gambit.canonicalPattern()}$`, 'i'); + matches = bit.match(canonicalPattern); + } + if (matches) { const negate = matches[1] === '-'; diff --git a/framework/core/js/src/common/query/IGambit.ts b/framework/core/js/src/common/query/IGambit.ts index 108e7e4c4d..f561d6002b 100644 --- a/framework/core/js/src/common/query/IGambit.ts +++ b/framework/core/js/src/common/query/IGambit.ts @@ -11,6 +11,13 @@ export default interface IGambit { */ pattern(): string; + /** + * The canonical English pattern for this gambit, used as an alias so that + * English always works regardless of the active locale. + * Implementations should derive this from canonicalKey() rather than key(). + */ + canonicalPattern(): string; + /** * This is the method to transform a gambit into a filter format. */ @@ -82,6 +89,16 @@ export abstract class BooleanGambit implements IGambit { abstract key(): string | string[]; abstract filterKey(): string; + /** + * The hardcoded English keyword(s) for this gambit, used as a canonical + * alias so that English always works regardless of the active locale. + * Defaults to key(), which is correct when the gambit is not translated. + * Override this in subclasses that translate key() via the translator. + */ + canonicalKey(): string | string[] { + return this.key(); + } + booleanKey(): 'is' | 'has' | 'allows' { return 'is'; } @@ -101,6 +118,18 @@ export abstract class BooleanGambit implements IGambit { return `${is}:(${key})`; } + canonicalPattern(): string { + // Always use the untranslated English group key and canonical keyword(s) + // so that English input works regardless of the active locale. + let key = this.canonicalKey(); + + if (Array.isArray(key)) { + key = key.join('|'); + } + + return `is:(${key})`; + } + toFilter(_matches: string[], negate: boolean): Record { const key = (negate ? '-' : '') + this.filterKey(); @@ -136,6 +165,16 @@ export abstract class KeyValueGambit implements IGambit { abstract hint(): string; abstract filterKey(): string; + /** + * The hardcoded English keyword for this gambit, used as a canonical + * alias so that English always works regardless of the active locale. + * Defaults to key(), which is correct when the gambit is not translated. + * Override this in subclasses that translate key() via the translator. + */ + canonicalKey(): string { + return this.key(); + } + valuePattern(): string { return '(.+)'; } @@ -154,6 +193,10 @@ export abstract class KeyValueGambit implements IGambit { return `${key}:` + this.valuePattern(); } + canonicalPattern(): string { + return `${this.canonicalKey()}:` + this.valuePattern(); + } + toFilter(matches: string[], negate: boolean): Record { const key = (negate ? '-' : '') + this.filterKey(); diff --git a/framework/core/js/src/common/utils/GambitsAutocomplete.tsx b/framework/core/js/src/common/utils/GambitsAutocomplete.tsx index 6379d612d2..5a41567136 100644 --- a/framework/core/js/src/common/utils/GambitsAutocomplete.tsx +++ b/framework/core/js/src/common/utils/GambitsAutocomplete.tsx @@ -45,6 +45,7 @@ export default class GambitsAutocomplete { .join(', '), }), pattern: () => '', + canonicalPattern: () => '', filterKey: () => '', toFilter: () => [], fromFilter: () => '', diff --git a/framework/core/js/tests/__mocks__/emptyModule.mjs b/framework/core/js/tests/__mocks__/emptyModule.mjs new file mode 100644 index 0000000000..ff8b4c5632 --- /dev/null +++ b/framework/core/js/tests/__mocks__/emptyModule.mjs @@ -0,0 +1 @@ +export default {}; diff --git a/framework/core/js/tests/__mocks__/nanoid.mjs b/framework/core/js/tests/__mocks__/nanoid.mjs new file mode 100644 index 0000000000..88f77efdad --- /dev/null +++ b/framework/core/js/tests/__mocks__/nanoid.mjs @@ -0,0 +1,2 @@ +let n = 0; +export const nanoid = () => String(++n); diff --git a/framework/core/js/tests/integration/common/GambitManager.test.ts b/framework/core/js/tests/integration/common/GambitManager.test.ts index 6e3ef68894..3057dfb49a 100644 --- a/framework/core/js/tests/integration/common/GambitManager.test.ts +++ b/framework/core/js/tests/integration/common/GambitManager.test.ts @@ -1,5 +1,6 @@ import bootstrapForum from '@flarum/jest-config/src/bootstrap/forum'; import GambitManager from '../../../src/common/GambitManager'; +import { BooleanGambit, KeyValueGambit } from '../../../src/common/query/IGambit'; import { app } from '../../../src/forum'; const gambits = new GambitManager(); @@ -11,6 +12,10 @@ describe('GambitManager', () => { app.boot(); }); + // ------------------------------------------------------------------------- + // Existing behaviour — must not regress + // ------------------------------------------------------------------------- + test('gambits are converted to filters', function () { expect(gambits.apply('discussions', { q: 'lorem created:2023-07-07 is:hidden author:behz' })).toStrictEqual({ q: 'lorem', @@ -43,4 +48,257 @@ describe('GambitManager', () => { } ); }); + + // ------------------------------------------------------------------------- + // Canonical keyword matching — English keys always work + // ------------------------------------------------------------------------- + + test('canonical English author keyword always matches', function () { + expect(gambits.apply('discussions', { q: 'author:admin' })).toStrictEqual({ + q: '', + author: 'admin', + }); + }); + + test('canonical English created keyword always matches', function () { + expect(gambits.apply('discussions', { q: 'created:2024-01-01' })).toStrictEqual({ + q: '', + created: '2024-01-01', + }); + }); + + test('canonical English is:hidden always matches', function () { + expect(gambits.apply('discussions', { q: 'is:hidden' })).toStrictEqual({ + q: '', + hidden: true, + }); + }); + + test('canonical English is:unread always matches', function () { + expect(gambits.apply('discussions', { q: 'is:unread' })).toStrictEqual({ + q: '', + unread: true, + }); + }); + + test('canonical English email keyword always matches for users', function () { + expect(gambits.apply('users', { q: 'email:admin@machine.local' })).toStrictEqual({ + q: '', + email: 'admin@machine.local', + }); + }); + + test('canonical English group keyword always matches for users', function () { + expect(gambits.apply('users', { q: 'group:admins' })).toStrictEqual({ + q: '', + group: 'admins', + }); + }); + + // ------------------------------------------------------------------------- + // Unrecognised keywords are left in the query string untouched + // ------------------------------------------------------------------------- + + test('unknown keyword is not consumed and remains in q', function () { + expect(gambits.apply('discussions', { q: 'lorem suivi:admin' })).toStrictEqual({ + q: 'lorem suivi:admin', + }); + }); + + test('unknown boolean keyword is not consumed', function () { + expect(gambits.apply('discussions', { q: 'is:versteckt' })).toStrictEqual({ + q: 'is:versteckt', + }); + }); + + // ------------------------------------------------------------------------- + // from() — filter object back to query string + // ------------------------------------------------------------------------- + + test('from() converts filter object back to a query string', function () { + const q = gambits.from('discussions', '', { author: 'admin', hidden: true }); + expect(q).toContain('author:admin'); + expect(q).toContain('is:hidden'); + }); + + test('from() ignores unknown filter keys', function () { + const q = gambits.from('discussions', 'lorem', { unknownKey: 'value' }); + expect(q).toBe('lorem'); + }); + + // ------------------------------------------------------------------------- + // match() — callback is called correctly + // ------------------------------------------------------------------------- + + test('match() calls the callback with the correct gambit, matches and negate=false', function () { + const calls: Array<{ filterKey: string; negate: boolean }> = []; + + gambits.match('discussions', 'author:behz', (gambit, _matches, negate) => { + calls.push({ filterKey: gambit.filterKey(), negate }); + }); + + expect(calls).toStrictEqual([{ filterKey: 'author', negate: false }]); + }); + + test('match() calls the callback with negate=true for a dashed gambit', function () { + const calls: Array<{ filterKey: string; negate: boolean }> = []; + + gambits.match('discussions', '-is:hidden', (gambit, _matches, negate) => { + calls.push({ filterKey: gambit.filterKey(), negate }); + }); + + expect(calls).toStrictEqual([{ filterKey: 'hidden', negate: true }]); + }); + + test('match() returns remaining query with matched gambits removed', function () { + const remaining = gambits.match('discussions', 'hello author:behz world', () => {}); + expect(remaining).toBe('hello world'); + }); + + test('match() returns full query unchanged when nothing matches', function () { + const remaining = gambits.match('discussions', 'hello world', () => {}); + expect(remaining).toBe('hello world'); + }); + + // ------------------------------------------------------------------------- + // apply() — filter accumulation edge cases + // ------------------------------------------------------------------------- + + test('apply() preserves other filter keys already on the object', function () { + expect(gambits.apply('discussions', { q: 'author:behz', tag: 'foo' })).toStrictEqual({ + q: '', + author: 'behz', + tag: 'foo', + }); + }); + + test('apply() with empty q returns filter unchanged', function () { + expect(gambits.apply('discussions', { q: '' })).toStrictEqual({ q: '' }); + }); + + test('apply() with unknown resource type returns filter unchanged', function () { + expect(gambits.apply('widgets', { q: 'author:behz' })).toStrictEqual({ q: 'author:behz' }); + }); + + // ------------------------------------------------------------------------- + // TDD: Localization alias support + // + // When a gambit's key() returns a translated value (e.g. '作者' for 'author'), + // both the translated keyword AND the English canonical keyword must match. + // The filter output must be identical in both cases. + // + // These tests will FAIL until GambitManager.match() is updated to try both + // the translated pattern and the canonical English pattern. + // ------------------------------------------------------------------------- + + describe('localization alias support', () => { + // Gambits whose key() returns a non-English translation but canonicalKey() + // returns the hardcoded English keyword — the pattern all translated gambits follow. + class TranslatedBooleanGambit extends BooleanGambit { + key() { return '隐藏'; } // Chinese for "hidden" + canonicalKey() { return 'hidden'; } + filterKey() { return 'hidden'; } + } + + class TranslatedKeyValueGambit extends KeyValueGambit { + key() { return '作者'; } // Chinese for "author" + canonicalKey() { return 'author'; } + hint() { return '用户名'; } + filterKey() { return 'author'; } + } + + let localizedGambits: GambitManager; + + beforeAll(() => { + localizedGambits = new GambitManager(); + localizedGambits.gambits = { + discussions: [TranslatedBooleanGambit, TranslatedKeyValueGambit], + }; + }); + + test('translated BooleanGambit keyword matches', () => { + expect(localizedGambits.apply('discussions', { q: 'is:隐藏' })).toStrictEqual({ + q: '', + hidden: true, + }); + }); + + test('English canonical BooleanGambit keyword still matches when key() is translated', () => { + // "is:hidden" must still work even though key() returns '隐藏' + expect(localizedGambits.apply('discussions', { q: 'is:hidden' })).toStrictEqual({ + q: '', + hidden: true, + }); + }); + + test('translated BooleanGambit and English canonical produce identical filter output', () => { + const translated = localizedGambits.apply('discussions', { q: 'is:隐藏' }); + const canonical = localizedGambits.apply('discussions', { q: 'is:hidden' }); + expect(translated).toStrictEqual(canonical); + }); + + test('translated KeyValueGambit keyword matches', () => { + expect(localizedGambits.apply('discussions', { q: '作者:behz' })).toStrictEqual({ + q: '', + author: 'behz', + }); + }); + + test('English canonical KeyValueGambit keyword still matches when key() is translated', () => { + // "author:behz" must still work even though key() returns '作者' + expect(localizedGambits.apply('discussions', { q: 'author:behz' })).toStrictEqual({ + q: '', + author: 'behz', + }); + }); + + test('translated KeyValueGambit and English canonical produce identical filter output', () => { + const translated = localizedGambits.apply('discussions', { q: '作者:behz' }); + const canonical = localizedGambits.apply('discussions', { q: 'author:behz' }); + expect(translated).toStrictEqual(canonical); + }); + + test('negation works with translated keyword', () => { + expect(localizedGambits.apply('discussions', { q: '-is:隐藏' })).toStrictEqual({ + q: '', + '-hidden': true, + }); + }); + + test('negation works with canonical keyword when key() is translated', () => { + expect(localizedGambits.apply('discussions', { q: '-is:hidden' })).toStrictEqual({ + q: '', + '-hidden': true, + }); + }); + + test('match() fires callback for translated keyword', () => { + const calls: string[] = []; + localizedGambits.match('discussions', 'is:隐藏', (gambit) => { + calls.push(gambit.filterKey()); + }); + expect(calls).toStrictEqual(['hidden']); + }); + + test('match() fires callback for canonical keyword when key() is translated', () => { + const calls: string[] = []; + localizedGambits.match('discussions', 'is:hidden', (gambit) => { + calls.push(gambit.filterKey()); + }); + expect(calls).toStrictEqual(['hidden']); + }); + + test('completely unrelated keyword is still not consumed', () => { + expect(localizedGambits.apply('discussions', { q: 'is:versteckt' })).toStrictEqual({ + q: 'is:versteckt', + }); + }); + + test('gambit with key() equal to the canonical English key does not double-match', () => { + // When key() already returns the English word, there is no alias — it should + // match once and not be applied twice. + const result = gambits.apply('discussions', { q: 'is:hidden' }); + expect(result).toStrictEqual({ q: '', hidden: true }); + }); + }); }); diff --git a/framework/core/js/tests/integration/common/gambits/IGambit.test.ts b/framework/core/js/tests/integration/common/gambits/IGambit.test.ts new file mode 100644 index 0000000000..734e6209cb --- /dev/null +++ b/framework/core/js/tests/integration/common/gambits/IGambit.test.ts @@ -0,0 +1,461 @@ +/** + * Tests for the individual gambit classes and the BooleanGambit / KeyValueGambit + * base class behaviour: pattern generation, toFilter, fromFilter, suggestion. + * + * These tests pin the current English-keyword behaviour so that any future + * localization alias work cannot silently regress the canonical paths. + */ +import bootstrapForum from '@flarum/jest-config/src/bootstrap/forum'; +import { app } from '../../../../src/forum'; + +import { BooleanGambit, KeyValueGambit, GambitType } from '../../../../src/common/query/IGambit'; +import AuthorGambit from '../../../../src/common/query/discussions/AuthorGambit'; +import CreatedGambit from '../../../../src/common/query/discussions/CreatedGambit'; +import HiddenGambit from '../../../../src/common/query/discussions/HiddenGambit'; +import UnreadGambit from '../../../../src/common/query/discussions/UnreadGambit'; +import EmailGambit from '../../../../src/common/query/users/EmailGambit'; +import GroupGambit from '../../../../src/common/query/users/GroupGambit'; + +beforeAll(() => { + bootstrapForum(); + app.boot(); +}); + +// --------------------------------------------------------------------------- +// AuthorGambit (KeyValueGambit) +// --------------------------------------------------------------------------- + +describe('AuthorGambit', () => { + let gambit: AuthorGambit; + + beforeEach(() => { + gambit = new AuthorGambit(); + }); + + test('type is KeyValue', () => { + expect(gambit.type).toBe(GambitType.KeyValue); + }); + + test('key() returns the English canonical key', () => { + expect(gambit.key()).toBe('author'); + }); + + test('filterKey() returns "author"', () => { + expect(gambit.filterKey()).toBe('author'); + }); + + test('pattern() returns "author:(.+)"', () => { + expect(gambit.pattern()).toBe('author:(.+)'); + }); + + test('toFilter() maps a match to the correct filter object', () => { + expect(gambit.toFilter(['author:behz', 'behz'], false)).toStrictEqual({ author: 'behz' }); + }); + + test('toFilter() negates the filter key when negate=true', () => { + expect(gambit.toFilter(['author:behz', 'behz'], true)).toStrictEqual({ '-author': 'behz' }); + }); + + test('fromFilter() reconstructs the gambit string', () => { + expect(gambit.fromFilter('behz', false)).toBe('author:behz'); + }); + + test('fromFilter() prefixes with dash when negated', () => { + expect(gambit.fromFilter('behz', true)).toBe('-author:behz'); + }); + + test('suggestion() returns key and hint', () => { + const s = gambit.suggestion(); + expect(s).toHaveProperty('key', 'author'); + expect(s).toHaveProperty('hint'); + }); + + test('enabled() is always true', () => { + expect(gambit.enabled()).toBe(true); + }); + + test('pattern matches a valid author string', () => { + expect('author:behz').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); + + test('pattern does not match an unrelated string', () => { + expect('auteur:behz').not.toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); +}); + +// --------------------------------------------------------------------------- +// CreatedGambit (KeyValueGambit with custom valuePattern) +// --------------------------------------------------------------------------- + +describe('CreatedGambit', () => { + let gambit: CreatedGambit; + + beforeEach(() => { + gambit = new CreatedGambit(); + }); + + test('key() returns "created"', () => { + expect(gambit.key()).toBe('created'); + }); + + test('filterKey() returns "created"', () => { + expect(gambit.filterKey()).toBe('created'); + }); + + test('pattern matches a single date', () => { + expect('created:2024-01-15').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); + + test('pattern matches a date range', () => { + expect('created:2024-01-01..2024-12-31').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); + + test('pattern does not match a plain word', () => { + expect('created:yesterday').not.toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); + + test('toFilter() passes the date string through', () => { + expect(gambit.toFilter(['created:2024-01-15', '2024-01-15'], false)).toStrictEqual({ created: '2024-01-15' }); + }); + + test('toFilter() passes a range string through', () => { + expect(gambit.toFilter(['created:2024-01-01..2024-12-31', '2024-01-01..2024-12-31'], false)).toStrictEqual({ + created: '2024-01-01..2024-12-31', + }); + }); + + test('fromFilter() reconstructs the gambit string', () => { + expect(gambit.fromFilter('2024-01-15', false)).toBe('created:2024-01-15'); + }); +}); + +// --------------------------------------------------------------------------- +// HiddenGambit (BooleanGambit) +// --------------------------------------------------------------------------- + +describe('HiddenGambit', () => { + let gambit: HiddenGambit; + + beforeEach(() => { + gambit = new HiddenGambit(); + }); + + test('type is Grouped', () => { + expect(gambit.type).toBe(GambitType.Grouped); + }); + + test('key() returns "hidden"', () => { + expect(gambit.key()).toBe('hidden'); + }); + + test('filterKey() returns "hidden"', () => { + expect(gambit.filterKey()).toBe('hidden'); + }); + + test('booleanKey() returns "is"', () => { + expect(gambit.booleanKey()).toBe('is'); + }); + + test('groupKey() returns the translated "is" group key', () => { + expect(gambit.groupKey()).toBe('is'); + }); + + test('pattern() returns "is:(hidden)"', () => { + expect(gambit.pattern()).toBe('is:(hidden)'); + }); + + test('toFilter() returns { hidden: true }', () => { + expect(gambit.toFilter(['is:hidden', 'hidden'], false)).toStrictEqual({ hidden: true }); + }); + + test('toFilter() returns { "-hidden": true } when negated', () => { + expect(gambit.toFilter(['is:hidden', 'hidden'], true)).toStrictEqual({ '-hidden': true }); + }); + + test('fromFilter() reconstructs "is:hidden"', () => { + expect(gambit.fromFilter('hidden', false)).toBe('is:hidden'); + }); + + test('fromFilter() reconstructs "-is:hidden" when negated', () => { + expect(gambit.fromFilter('hidden', true)).toBe('-is:hidden'); + }); + + test('suggestion() returns group and key', () => { + const s = gambit.suggestion(); + expect(s).toHaveProperty('group', 'is'); + expect(s).toHaveProperty('key', 'hidden'); + }); + + test('pattern matches "is:hidden"', () => { + expect('is:hidden').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); + + test('pattern does not match a different keyword', () => { + expect('is:versteckt').not.toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); +}); + +// --------------------------------------------------------------------------- +// UnreadGambit (BooleanGambit) +// --------------------------------------------------------------------------- + +describe('UnreadGambit', () => { + let gambit: UnreadGambit; + + beforeEach(() => { + gambit = new UnreadGambit(); + }); + + test('key() returns "unread"', () => { + expect(gambit.key()).toBe('unread'); + }); + + test('filterKey() returns "unread"', () => { + expect(gambit.filterKey()).toBe('unread'); + }); + + test('pattern() returns "is:(unread)"', () => { + expect(gambit.pattern()).toBe('is:(unread)'); + }); + + test('toFilter() returns { unread: true }', () => { + expect(gambit.toFilter(['is:unread', 'unread'], false)).toStrictEqual({ unread: true }); + }); + + test('pattern matches "is:unread"', () => { + expect('is:unread').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); +}); + +// --------------------------------------------------------------------------- +// EmailGambit (KeyValueGambit) +// --------------------------------------------------------------------------- + +describe('EmailGambit', () => { + let gambit: EmailGambit; + + beforeEach(() => { + gambit = new EmailGambit(); + }); + + test('key() returns "email"', () => { + expect(gambit.key()).toBe('email'); + }); + + test('filterKey() returns "email"', () => { + expect(gambit.filterKey()).toBe('email'); + }); + + test('pattern() returns "email:(.+)"', () => { + expect(gambit.pattern()).toBe('email:(.+)'); + }); + + test('toFilter() maps correctly', () => { + expect(gambit.toFilter(['email:foo@bar.com', 'foo@bar.com'], false)).toStrictEqual({ email: 'foo@bar.com' }); + }); + + test('fromFilter() reconstructs correctly', () => { + expect(gambit.fromFilter('foo@bar.com', false)).toBe('email:foo@bar.com'); + }); + + test('pattern matches a valid email string', () => { + expect('email:foo@bar.com').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); +}); + +// --------------------------------------------------------------------------- +// GroupGambit (KeyValueGambit) +// --------------------------------------------------------------------------- + +describe('GroupGambit', () => { + let gambit: GroupGambit; + + beforeEach(() => { + gambit = new GroupGambit(); + }); + + test('key() returns "group"', () => { + expect(gambit.key()).toBe('group'); + }); + + test('filterKey() returns "group"', () => { + expect(gambit.filterKey()).toBe('group'); + }); + + test('pattern() returns "group:(.+)"', () => { + expect(gambit.pattern()).toBe('group:(.+)'); + }); + + test('toFilter() maps correctly', () => { + expect(gambit.toFilter(['group:admins', 'admins'], false)).toStrictEqual({ group: 'admins' }); + }); + + test('fromFilter() reconstructs correctly', () => { + expect(gambit.fromFilter('admins', false)).toBe('group:admins'); + }); +}); + +// --------------------------------------------------------------------------- +// BooleanGambit base class — array key support +// --------------------------------------------------------------------------- + +describe('BooleanGambit with array key()', () => { + class MultiKeyGambit extends BooleanGambit { + key() { + return ['foo', 'bar']; + } + filterKey() { + return 'multi'; + } + } + + let gambit: MultiKeyGambit; + + beforeEach(() => { + gambit = new MultiKeyGambit(); + }); + + test('pattern() joins multiple keys with | inside the group', () => { + expect(gambit.pattern()).toBe('is:(foo|bar)'); + }); + + test('pattern matches first key', () => { + expect('is:foo').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); + + test('pattern matches second key', () => { + expect('is:bar').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); + + test('pattern does not match an unlisted key', () => { + expect('is:baz').not.toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); +}); + +// --------------------------------------------------------------------------- +// KeyValueGambit base class — custom valuePattern +// --------------------------------------------------------------------------- + +describe('KeyValueGambit with custom valuePattern()', () => { + class DateOnlyGambit extends KeyValueGambit { + key() { return 'on'; } + hint() { return 'YYYY-MM-DD'; } + filterKey() { return 'on'; } + valuePattern() { return '(\\d{4}-\\d{2}-\\d{2})'; } + } + + let gambit: DateOnlyGambit; + + beforeEach(() => { + gambit = new DateOnlyGambit(); + }); + + test('pattern() uses the custom valuePattern', () => { + expect(gambit.pattern()).toBe('on:(\\d{4}-\\d{2}-\\d{2})'); + }); + + test('pattern matches a valid date', () => { + expect('on:2024-01-01').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); + + test('pattern does not match free text', () => { + expect('on:yesterday').not.toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); +}); + +// --------------------------------------------------------------------------- +// TDD: canonicalKey() — the English fallback key +// +// Each gambit base class must expose a canonicalKey() method that returns the +// hardcoded English keyword, independent of what key() returns (which may be +// translated). GambitManager will use this to build an alias pattern so that +// the English keyword always works regardless of locale. +// +// These tests will FAIL until canonicalKey() is added to BooleanGambit and +// KeyValueGambit in IGambit.ts. +// --------------------------------------------------------------------------- + +describe('TDD: BooleanGambit canonicalKey()', () => { + class TranslatedBooleanGambit extends BooleanGambit { + // Simulates a gambit whose key() returns a Chinese translation. + // canonicalKey() is overridden to return the hardcoded English keyword — + // this is the pattern every translated gambit subclass must follow. + key() { return '隐藏'; } + canonicalKey() { return 'hidden'; } + filterKey() { return 'hidden'; } + } + + class UntranslatedBooleanGambit extends BooleanGambit { + // No canonicalKey() override — defaults to key(), which is already English. + key() { return 'hidden'; } + filterKey() { return 'hidden'; } + } + + test('canonicalKey() exists on BooleanGambit', () => { + const gambit = new UntranslatedBooleanGambit(); + expect(typeof gambit.canonicalKey).toBe('function'); + }); + + test('canonicalKey() returns a string or string[] like key()', () => { + const gambit = new UntranslatedBooleanGambit(); + const canonical = gambit.canonicalKey(); + expect(typeof canonical === 'string' || Array.isArray(canonical)).toBe(true); + }); + + test('canonicalKey() returns the English keyword when key() is English', () => { + const gambit = new UntranslatedBooleanGambit(); + expect(gambit.canonicalKey()).toBe('hidden'); + }); + + test('canonicalKey() returns the English keyword even when key() is translated', () => { + const gambit = new TranslatedBooleanGambit(); + // key() returns '隐藏', but canonicalKey() must still return 'hidden' + expect(gambit.canonicalKey()).toBe('hidden'); + }); + + test('canonicalPattern() builds the English fallback pattern', () => { + const gambit = new TranslatedBooleanGambit(); + expect(typeof (gambit as any).canonicalPattern).toBe('function'); + expect((gambit as any).canonicalPattern()).toBe('is:(hidden)'); + }); +}); + +describe('TDD: KeyValueGambit canonicalKey()', () => { + class TranslatedKeyValueGambit extends KeyValueGambit { + // Simulates a gambit whose key() returns a Chinese translation. + // canonicalKey() is overridden to return the hardcoded English keyword — + // this is the pattern every translated gambit subclass must follow. + key() { return '作者'; } + canonicalKey() { return 'author'; } + hint() { return '用户名'; } + filterKey() { return 'author'; } + } + + class UntranslatedKeyValueGambit extends KeyValueGambit { + // No canonicalKey() override — defaults to key(), which is already English. + key() { return 'author'; } + hint() { return 'username'; } + filterKey() { return 'author'; } + } + + test('canonicalKey() exists on KeyValueGambit', () => { + const gambit = new UntranslatedKeyValueGambit(); + expect(typeof gambit.canonicalKey).toBe('function'); + }); + + test('canonicalKey() returns the English keyword when key() is English', () => { + const gambit = new UntranslatedKeyValueGambit(); + expect(gambit.canonicalKey()).toBe('author'); + }); + + test('canonicalKey() returns the English keyword even when key() is translated', () => { + const gambit = new TranslatedKeyValueGambit(); + expect(gambit.canonicalKey()).toBe('author'); + }); + + test('canonicalPattern() builds the English fallback pattern', () => { + const gambit = new TranslatedKeyValueGambit(); + expect(typeof (gambit as any).canonicalPattern).toBe('function'); + expect((gambit as any).canonicalPattern()).toBe('author:(.+)'); + }); +});