From a51bd3db3b40cb0cdd831645c4d9c346758d19ac Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:41:14 +0400 Subject: [PATCH 01/16] [#533] Introduce Lang adapter foundation --- src/Lang/Adapters/FileAdapter.php | 112 ++++++++++++++++++ src/Lang/Contracts/LangAdapterInterface.php | 25 ++++ src/Lang/Enums/LangType.php | 24 ++++ src/Lang/Factories/LangFactory.php | 69 +++++++---- src/Lang/Lang.php | 30 ++--- src/Lang/Translator.php | 94 +-------------- tests/Unit/Lang/Factories/LangFactoryTest.php | 28 ++++- tests/_root/shared/config/lang.php | 1 + 8 files changed, 253 insertions(+), 130 deletions(-) create mode 100644 src/Lang/Adapters/FileAdapter.php create mode 100644 src/Lang/Contracts/LangAdapterInterface.php create mode 100644 src/Lang/Enums/LangType.php diff --git a/src/Lang/Adapters/FileAdapter.php b/src/Lang/Adapters/FileAdapter.php new file mode 100644 index 000000000..ce31987e6 --- /dev/null +++ b/src/Lang/Adapters/FileAdapter.php @@ -0,0 +1,112 @@ +lang = $lang; + } + + public function setLang(string $lang): LangAdapterInterface + { + if ($this->lang !== $lang) { + $this->lang = $lang; + $this->flush(); + } + + return $this; + } + + /** + * @throws LangException|LoaderException|ConfigException|DiException|BaseException|ReflectionException + */ + public function loadTranslations(): void + { + if ($this->translations !== null) { + return; + } + + $this->translations = new Data(); + $loaded = false; + + $sharedDir = base_dir() . DS . 'shared' . DS . 'resources' . DS . 'lang' . DS . $this->lang; + $sharedFiles = fs()->glob($sharedDir . DS . '*.php'); + + if (is_array($sharedFiles) && count($sharedFiles)) { + $this->loadFiles($sharedFiles); + $loaded = true; + } + + $moduleDir = modules_dir() . DS . request()->getCurrentModule() . DS . 'resources' . DS . 'lang' . DS . $this->lang; + $moduleFiles = fs()->glob($moduleDir . DS . '*.php'); + + if (is_array($moduleFiles) && count($moduleFiles)) { + $this->loadFiles($moduleFiles); + $loaded = true; + } + + if (!$loaded) { + throw LangException::translationsNotFound(); + } + } + + /** + * @param array|string|null $params + */ + public function get(string $key, array|string $params = null): string + { + if ($this->translations && $this->translations->has($key)) { + $message = $this->translations->get($key); + return $params ? _message($message, $params) : $message; + } + + return $key; + } + + public function flush(): void + { + $this->translations = null; + } + + /** + * @param array $files + * @throws ConfigException|DiException|BaseException|ReflectionException + */ + private function loadFiles(array $files): void + { + if ($this->translations === null) { + return; + } + + foreach ($files as $file) { + $fileName = fs()->fileName($file); + + $this->translations->import([ + $fileName => fs()->require($file), + ]); + } + } +} diff --git a/src/Lang/Contracts/LangAdapterInterface.php b/src/Lang/Contracts/LangAdapterInterface.php new file mode 100644 index 000000000..32f30d53b --- /dev/null +++ b/src/Lang/Contracts/LangAdapterInterface.php @@ -0,0 +1,25 @@ +|null $params + */ + public function get(string $key, array|string $params = null): string; + + public function loadTranslations(): void; + + public function flush(): void; +} diff --git a/src/Lang/Enums/LangType.php b/src/Lang/Enums/LangType.php new file mode 100644 index 000000000..906f2d114 --- /dev/null +++ b/src/Lang/Enums/LangType.php @@ -0,0 +1,24 @@ + FileAdapter::class, + ]; + + /** + * @var array + */ + private array $instances = []; /** * @throws LangException|ConfigException|LoaderException|DiException|ReflectionException */ - public static function get(): Lang + public static function get(?string $adapter = null): Lang { if (!Di::isRegistered(self::class)) { Di::register(self::class); } - return Di::get(self::class)->resolve(); + return Di::get(self::class)->resolve($adapter); } /** - * @throws LangException|ConfigException|DiException|ReflectionException|LoaderException + * @throws LangException|ConfigException|DiException|ReflectionException|LoaderException|BaseException */ - public function resolve(): Lang + public function resolve(?string $adapter = null): Lang { - if ($this->instance !== null) { - return $this->instance; + if (!config()->has('lang')) { + config()->import(new Setup('config', 'lang')); } - [$isEnabled, $supported, $default] = $this->loadLangConfig(); + $isEnabled = filter_var(config()->get('lang.enabled'), FILTER_VALIDATE_BOOLEAN); + $supported = (array) config()->get('lang.supported'); + $default = config()->get('lang.default'); + $adapter ??= config()->get('lang.adapter'); $lang = $this->detectLanguage($supported, $default); - $translator = new Translator($lang); + if (!isset($this->instances[$adapter])) { + $adapterClass = $this->getAdapterClass($adapter); + $this->instances[$adapter] = new Lang($lang, $isEnabled, $this->createAdapter($adapterClass, $lang, $adapter)); + } - return $this->instance = new Lang($lang, $isEnabled, $translator); + return $this->instances[$adapter]; } /** - * @return array{0: bool, 1: array, 2: string} - * @throws LoaderException|ConfigException|DiException|ReflectionException + * @throws BaseException */ - private function loadLangConfig(): array + private function getAdapterClass(string $adapter): string { - if (!config()->has('lang')) { - config()->import(new Setup('config', 'lang')); + if (!array_key_exists($adapter, self::ADAPTERS)) { + throw LangException::adapterNotSupported($adapter); + } + + return self::ADAPTERS[$adapter]; + } + + /** + * @throws BaseException + */ + private function createAdapter(string $adapterClass, string $lang, string $adapter): LangAdapterInterface + { + $langAdapter = new $adapterClass($lang); + + if (!$langAdapter instanceof LangAdapterInterface) { + throw LangException::adapterNotSupported($adapter); } - return [ - filter_var(config()->get('lang.enabled'), FILTER_VALIDATE_BOOLEAN), - (array) config()->get('lang.supported'), - config()->get('lang.default'), - ]; + return $langAdapter; } /** diff --git a/src/Lang/Lang.php b/src/Lang/Lang.php index 84b6df126..4c343603f 100644 --- a/src/Lang/Lang.php +++ b/src/Lang/Lang.php @@ -10,12 +10,7 @@ namespace Quantum\Lang; -use Quantum\Config\Exceptions\ConfigException; -use Quantum\Loader\Exceptions\LoaderException; -use Quantum\Lang\Exceptions\LangException; -use Quantum\App\Exceptions\BaseException; -use Quantum\Di\Exceptions\DiException; -use ReflectionException; +use Quantum\Lang\Contracts\LangAdapterInterface; /** * Class Lang @@ -25,14 +20,14 @@ class Lang { private ?string $currentLang = null; - private Translator $translator; + private LangAdapterInterface $adapter; private bool $isEnabled; - public function __construct(string $lang, bool $isEnabled, Translator $translator) + public function __construct(string $lang, bool $isEnabled, LangAdapterInterface $adapter) { $this->isEnabled = $isEnabled; - $this->translator = $translator; + $this->adapter = $adapter; $this->setLang($lang); } @@ -43,6 +38,7 @@ public function __construct(string $lang, bool $isEnabled, Translator $translato public function setLang(string $lang): self { $this->currentLang = $lang; + $this->adapter->setLang($lang); return $this; } @@ -62,22 +58,26 @@ public function isEnabled(): bool return $this->isEnabled; } + public function getAdapter(): LangAdapterInterface + { + return $this->adapter; + } + /** * Load translations - * @throws LangException|LoaderException|ConfigException|DiException|BaseException|ReflectionException */ public function load(): void { - $this->translator->loadTranslations(); + $this->adapter->loadTranslations(); } /** * Get translation by key - * @param array|string|null $params + * @param string|array|null $params */ - public function getTranslation(string $key, $params = null): ?string + public function getTranslation(string $key, array|string $params = null): ?string { - return $this->translator->get($key, $params); + return $this->adapter->get($key, $params); } /** @@ -85,6 +85,6 @@ public function getTranslation(string $key, $params = null): ?string */ public function flush(): void { - $this->translator->flush(); + $this->adapter->flush(); } } diff --git a/src/Lang/Translator.php b/src/Lang/Translator.php index aef2dd5c3..55124a088 100644 --- a/src/Lang/Translator.php +++ b/src/Lang/Translator.php @@ -10,102 +10,12 @@ namespace Quantum\Lang; -use Quantum\Config\Exceptions\ConfigException; -use Quantum\Loader\Exceptions\LoaderException; -use Quantum\Lang\Exceptions\LangException; -use Quantum\App\Exceptions\BaseException; -use Quantum\Di\Exceptions\DiException; -use Dflydev\DotAccessData\Data; -use ReflectionException; +use Quantum\Lang\Adapters\FileAdapter; /** * Class Translator * @package Quantum\Lang */ -class Translator +class Translator extends FileAdapter { - protected string $lang; - - private ?Data $translations = null; - - public function __construct(string $lang) - { - $this->lang = $lang; - } - - /** - * Load translation files - * @throws LangException|LoaderException|ConfigException|DiException|BaseException|ReflectionException - */ - public function loadTranslations(): void - { - if ($this->translations !== null) { - return; - } - - $this->translations = new Data(); - $loaded = false; - - $sharedDir = base_dir() . DS . 'shared' . DS . 'resources' . DS . 'lang' . DS . $this->lang; - $sharedFiles = fs()->glob($sharedDir . DS . '*.php'); - - if (is_array($sharedFiles) && count($sharedFiles)) { - $this->loadFiles($sharedFiles); - $loaded = true; - } - - $moduleDir = modules_dir() . DS . request()->getCurrentModule() . DS . 'resources' . DS . 'lang' . DS . $this->lang; - $moduleFiles = fs()->glob($moduleDir . DS . '*.php'); - - if (is_array($moduleFiles) && count($moduleFiles)) { - $this->loadFiles($moduleFiles); - $loaded = true; - } - - if (!$loaded) { - throw LangException::translationsNotFound(); - } - } - - /** - * Load translations - * @param array $files - * @throws ConfigException|DiException|BaseException|ReflectionException - */ - private function loadFiles(array $files): void - { - if ($this->translations === null) { - return; - } - - foreach ($files as $file) { - $fileName = fs()->fileName($file); - - $this->translations->import([ - $fileName => fs()->require($file), - ]); - } - } - - /** - * Get translation by key - * @param array|string|null $params - */ - public function get(string $key, $params = null): string - { - if ($this->translations && $this->translations->has($key)) { - $message = $this->translations->get($key); - return $params ? _message($message, $params) : $message; - } - - return $key; - } - - /** - * Reset translations - */ - public function flush(): void - { - $this->translations = null; - } } diff --git a/tests/Unit/Lang/Factories/LangFactoryTest.php b/tests/Unit/Lang/Factories/LangFactoryTest.php index e003de595..1da6ee1d5 100644 --- a/tests/Unit/Lang/Factories/LangFactoryTest.php +++ b/tests/Unit/Lang/Factories/LangFactoryTest.php @@ -4,6 +4,7 @@ use Quantum\Lang\Exceptions\LangException; use Quantum\Lang\Factories\LangFactory; +use Quantum\Lang\Adapters\FileAdapter; use Quantum\Tests\Unit\AppTestCase; use Quantum\Lang\Lang; use Quantum\Di\Di; @@ -37,6 +38,13 @@ public function testLangFactoryGetReturnsSameInstance(): void $this->assertSame($first, $second); } + public function testLangFactoryResolvesFileAdapter(): void + { + $lang = LangFactory::get(); + + $this->assertInstanceOf(FileAdapter::class, $lang->getAdapter()); + } + public function testLangFactoryDetectedFromRouteParameter(): void { $this->testRequest('http://127.0.0.1/es/api/rest'); @@ -78,6 +86,7 @@ public function testLangFactoryFallsBackToDefaultIfProvidedLangIsNotSupported(): config()->set('lang', [ 'enabled' => true, 'default' => 'en', + 'adapter' => 'file', 'supported' => ['en', 'es'], 'url_segment' => 1, ]); @@ -110,6 +119,7 @@ public function testLangFactoryThrowsErrorIfNoDefaultLangFound(): void config()->set('lang', [ 'enabled' => true, 'default' => null, + 'adapter' => 'file', 'supported' => ['en', 'es'], 'url_segment' => 1, ]); @@ -123,6 +133,22 @@ public function testLangFactoryThrowsErrorIfNoDefaultLangFound(): void LangFactory::get(); } + public function testLangFactoryThrowsErrorIfAdapterIsNotSupported(): void + { + config()->set('lang', [ + 'enabled' => true, + 'default' => 'en', + 'adapter' => 'unknown', + 'supported' => ['en', 'es'], + 'url_segment' => 1, + ]); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('The adapter `unknown` is not supported.'); + + LangFactory::get(); + } + private function resetLangFactory(): void { if (!Di::isRegistered(LangFactory::class)) { @@ -130,6 +156,6 @@ private function resetLangFactory(): void } $factory = Di::get(LangFactory::class); - $this->setPrivateProperty($factory, 'instance', null); + $this->setPrivateProperty($factory, 'instances', []); } } diff --git a/tests/_root/shared/config/lang.php b/tests/_root/shared/config/lang.php index 407b240d0..c7277f0fb 100644 --- a/tests/_root/shared/config/lang.php +++ b/tests/_root/shared/config/lang.php @@ -9,5 +9,6 @@ 'enabled' => true, 'supported' => ['en', 'es'], 'default' => 'en', + 'adapter' => 'file', 'url_segment' => 1, ]; From b9bd5002343614fb108ab81ad6ccc728ed06b87d Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:47:30 +0400 Subject: [PATCH 02/16] [#533] Align Lang wrapper with adapter pattern --- src/Lang/Lang.php | 16 +++++++++ src/Lang/Translator.php | 21 ----------- tests/Unit/Lang/Adapters/FileAdapterTest.php | 38 ++++++++++++++++++++ tests/Unit/Lang/LangTest.php | 7 ++-- tests/Unit/Lang/TranslatorTest.php | 38 -------------------- 5 files changed, 57 insertions(+), 63 deletions(-) delete mode 100644 src/Lang/Translator.php create mode 100644 tests/Unit/Lang/Adapters/FileAdapterTest.php delete mode 100644 tests/Unit/Lang/TranslatorTest.php diff --git a/src/Lang/Lang.php b/src/Lang/Lang.php index 4c343603f..3fe8174b9 100644 --- a/src/Lang/Lang.php +++ b/src/Lang/Lang.php @@ -11,6 +11,8 @@ namespace Quantum\Lang; use Quantum\Lang\Contracts\LangAdapterInterface; +use Quantum\App\Exceptions\BaseException; +use Quantum\Lang\Exceptions\LangException; /** * Class Lang @@ -87,4 +89,18 @@ public function flush(): void { $this->adapter->flush(); } + + /** + * @param array $arguments + * @return mixed + * @throws BaseException + */ + public function __call(string $method, ?array $arguments) + { + if (!method_exists($this->adapter, $method)) { + throw LangException::methodNotSupported($method, $this->adapter::class); + } + + return $this->adapter->$method(...$arguments); + } } diff --git a/src/Lang/Translator.php b/src/Lang/Translator.php deleted file mode 100644 index 55124a088..000000000 --- a/src/Lang/Translator.php +++ /dev/null @@ -1,21 +0,0 @@ -assertInstanceOf(FileAdapter::class, $adapter); + } + + public function testFileAdapterLoadTranslations(): void + { + $adapter = new FileAdapter('en'); + + $adapter->loadTranslations(); + + $this->assertEquals('Testing', $adapter->get('custom.test')); + + $this->assertEquals('Information about the value feature', $adapter->get('custom.info', ['param' => 'value'])); + } + + public function testFileAdapterGetTranslation(): void + { + $adapter = new FileAdapter('en'); + + $adapter->loadTranslations(); + + $result = $adapter->get('custom.info', ['new']); + + $this->assertEquals('Information about the new feature', $result); + } +} diff --git a/tests/Unit/Lang/LangTest.php b/tests/Unit/Lang/LangTest.php index 18ddf628c..9b82f6009 100644 --- a/tests/Unit/Lang/LangTest.php +++ b/tests/Unit/Lang/LangTest.php @@ -2,9 +2,9 @@ namespace Quantum\Tests\Unit\Lang; +use Quantum\Lang\Adapters\FileAdapter; use Quantum\Tests\Unit\AppTestCase; use Quantum\Router\MatchedRoute; -use Quantum\Lang\Translator; use Quantum\Router\Route; use Quantum\Lang\Lang; @@ -16,8 +16,7 @@ public function setUp(): void { parent::setUp(); - $translator = new Translator('en'); - $this->lang = new Lang('en', true, $translator); + $this->lang = new Lang('en', true, new FileAdapter('en')); $route = new Route( ['POST'], @@ -46,7 +45,7 @@ public function testLangIsEnabled(): void { $this->assertTrue($this->lang->isEnabled()); - $langDisabled = new Lang('en', false, new Translator('en')); + $langDisabled = new Lang('en', false, new FileAdapter('en')); $this->assertFalse($langDisabled->isEnabled()); } diff --git a/tests/Unit/Lang/TranslatorTest.php b/tests/Unit/Lang/TranslatorTest.php deleted file mode 100644 index c66794a60..000000000 --- a/tests/Unit/Lang/TranslatorTest.php +++ /dev/null @@ -1,38 +0,0 @@ -assertInstanceOf(Translator::class, $translator); - } - - public function testTranslatorLoadTranslations(): void - { - $translator = new Translator('en'); - - $translator->loadTranslations(); - - $this->assertEquals('Testing', $translator->get('custom.test')); - - $this->assertEquals('Information about the value feature', $translator->get('custom.info', ['param' => 'value'])); - } - - public function testTranslatorGetTranslation(): void - { - $translator = new Translator('en'); - - $translator->loadTranslations(); - - $result = $translator->get('custom.info', ['new']); - - $this->assertEquals('Information about the new feature', $result); - } -} From 9ee2f94dcd2222d1cec96016208b18b55b905484 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:41:24 +0400 Subject: [PATCH 03/16] [#533] Normalize Lang config contract --- src/Lang/Factories/LangFactory.php | 6 +++--- tests/Unit/Lang/Factories/LangFactoryTest.php | 15 +++++++++------ tests/_root/shared/config/lang.php | 5 +++-- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/Lang/Factories/LangFactory.php b/src/Lang/Factories/LangFactory.php index dd41e3610..025f48c07 100644 --- a/src/Lang/Factories/LangFactory.php +++ b/src/Lang/Factories/LangFactory.php @@ -15,8 +15,8 @@ use Quantum\Config\Exceptions\ConfigException; use Quantum\Lang\Exceptions\LangException; use Quantum\App\Exceptions\BaseException; -use Quantum\Di\Exceptions\DiException; use Quantum\Lang\Adapters\FileAdapter; +use Quantum\Di\Exceptions\DiException; use Quantum\Lang\Enums\LangType; use Quantum\Loader\Setup; use ReflectionException; @@ -61,8 +61,8 @@ public function resolve(?string $adapter = null): Lang $isEnabled = filter_var(config()->get('lang.enabled'), FILTER_VALIDATE_BOOLEAN); $supported = (array) config()->get('lang.supported'); - $default = config()->get('lang.default'); - $adapter ??= config()->get('lang.adapter'); + $default = config()->get('lang.default_locale'); + $adapter ??= config()->get('lang.default'); $lang = $this->detectLanguage($supported, $default); diff --git a/tests/Unit/Lang/Factories/LangFactoryTest.php b/tests/Unit/Lang/Factories/LangFactoryTest.php index 1da6ee1d5..d39173d91 100644 --- a/tests/Unit/Lang/Factories/LangFactoryTest.php +++ b/tests/Unit/Lang/Factories/LangFactoryTest.php @@ -85,8 +85,9 @@ public function testLangFactoryFallsBackToDefaultIfProvidedLangIsNotSupported(): { config()->set('lang', [ 'enabled' => true, - 'default' => 'en', - 'adapter' => 'file', + 'default' => 'file', + 'default_locale' => 'en', + 'file' => [], 'supported' => ['en', 'es'], 'url_segment' => 1, ]); @@ -118,8 +119,9 @@ public function testLangFactoryThrowsErrorIfNoDefaultLangFound(): void { config()->set('lang', [ 'enabled' => true, - 'default' => null, - 'adapter' => 'file', + 'default' => 'file', + 'default_locale' => null, + 'file' => [], 'supported' => ['en', 'es'], 'url_segment' => 1, ]); @@ -137,8 +139,9 @@ public function testLangFactoryThrowsErrorIfAdapterIsNotSupported(): void { config()->set('lang', [ 'enabled' => true, - 'default' => 'en', - 'adapter' => 'unknown', + 'default' => 'unknown', + 'default_locale' => 'en', + 'file' => [], 'supported' => ['en', 'es'], 'url_segment' => 1, ]); diff --git a/tests/_root/shared/config/lang.php b/tests/_root/shared/config/lang.php index c7277f0fb..860008929 100644 --- a/tests/_root/shared/config/lang.php +++ b/tests/_root/shared/config/lang.php @@ -8,7 +8,8 @@ */ 'enabled' => true, 'supported' => ['en', 'es'], - 'default' => 'en', - 'adapter' => 'file', + 'default' => 'file', + 'default_locale' => 'en', + 'file' => [], 'url_segment' => 1, ]; From b88e5a1f5b9d3595f4d925b2470d4407293e8332 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:17:08 +0400 Subject: [PATCH 04/16] [#533] Add remote Lang adapters and lazy loading --- CHANGELOG.md | 8 ++ src/App/Adapters/WebAppAdapter.php | 2 - src/App/Traits/WebAppTrait.php | 14 -- src/Lang/Adapters/DeepLAdapter.php | 109 ++++++++++++++ src/Lang/Adapters/FileAdapter.php | 15 +- src/Lang/Adapters/GoogleTranslateAdapter.php | 105 ++++++++++++++ src/Lang/Contracts/LangAdapterInterface.php | 2 - src/Lang/Enums/ExceptionMessages.php | 4 + src/Lang/Exceptions/LangException.php | 18 +++ src/Lang/Factories/LangFactory.php | 19 ++- src/Lang/Lang.php | 8 -- src/Lang/Traits/RemoteAdapterTrait.php | 134 ++++++++++++++++++ .../Lang/Helpers/LangHelperFunctionsTest.php | 2 - tests/Unit/Lang/LangTest.php | 8 +- tests/_root/shared/config/lang.php | 20 +++ 15 files changed, 427 insertions(+), 41 deletions(-) create mode 100644 src/Lang/Adapters/DeepLAdapter.php create mode 100644 src/Lang/Adapters/GoogleTranslateAdapter.php create mode 100644 src/Lang/Traits/RemoteAdapterTrait.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a90d2981..4e752704a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog. +## [3.1.0] - TBD + +### Added +- Added adapter-based Lang provider support with `DeepL` and `Google Translate` adapters plus shared remote request/caching infrastructure (#533) + +### Changed +- Refactored the Lang package to resolve adapter instances through `LangFactory` configuration and load file translations lazily on first use instead of preloading them during web boot (#533) + ## [3.0.3] - 2026-07-10 ### Changed diff --git a/src/App/Adapters/WebAppAdapter.php b/src/App/Adapters/WebAppAdapter.php index 1f629231a..52ef15e48 100644 --- a/src/App/Adapters/WebAppAdapter.php +++ b/src/App/Adapters/WebAppAdapter.php @@ -80,8 +80,6 @@ public function start(): ?int return ExitCode::SUCCESS; } - $this->loadLanguage(); - $this->logDebugInfo(); $viewCache = $this->setupViewCache(); diff --git a/src/App/Traits/WebAppTrait.php b/src/App/Traits/WebAppTrait.php index 5bd97a0d1..b746e5fa0 100644 --- a/src/App/Traits/WebAppTrait.php +++ b/src/App/Traits/WebAppTrait.php @@ -13,9 +13,7 @@ use Quantum\Config\Exceptions\ConfigException; use Quantum\Loader\Exceptions\LoaderException; use Quantum\Router\Exceptions\RouteException; -use Quantum\Lang\Exceptions\LangException; use Quantum\App\Exceptions\BaseException; -use Quantum\Lang\Factories\LangFactory; use Quantum\Di\Exceptions\DiException; use Quantum\ResourceCache\ViewCache; use Quantum\Router\RouteCollection; @@ -52,18 +50,6 @@ private function resolveRoute(): ?MatchedRoute return $matchedRoute; } - /** - * @throws LangException|ConfigException|DiException|BaseException|ReflectionException - */ - private function loadLanguage(): void - { - $lang = LangFactory::get(); - - if ($lang->isEnabled()) { - $lang->load(); - } - } - /** * @throws DiException|ReflectionException */ diff --git a/src/Lang/Adapters/DeepLAdapter.php b/src/Lang/Adapters/DeepLAdapter.php new file mode 100644 index 000000000..469b5074c --- /dev/null +++ b/src/Lang/Adapters/DeepLAdapter.php @@ -0,0 +1,109 @@ + $params + */ + public function __construct(string $lang, array $params, ?HttpClient $httpClient = null) + { + $this->lang = $lang; + $this->params = $params; + $this->httpClient = $httpClient ?? new HttpClient(); + } + + public function setLang(string $lang): LangAdapterInterface + { + $this->lang = $lang; + return $this; + } + + /** + * @param array|string|null $params + */ + public function get(string $key, $params = null): string + { + $text = $this->buildSourceText($key, $params); + + if ($text === '') { + return $text; + } + + $cached = $this->getCachedTranslation('deepl', $text); + + if ($cached !== null) { + return $cached; + } + + $authKey = (string) ($this->params['auth_key'] ?? ''); + + if ($authKey === '') { + throw LangException::missingConfig('lang.deepl.auth_key'); + } + + $payload = [ + 'text' => [$text], + 'target_lang' => strtoupper($this->lang), + ]; + + if (!empty($this->params['source_locale'])) { + $payload['source_lang'] = strtoupper((string) $this->params['source_locale']); + } + + $payloadJson = json_encode($payload); + + if ($payloadJson === false) { + throw LangException::invalidProviderResponse('DeepL'); + } + + $response = $this->sendRequest( + (string) ($this->params['api_url'] ?? self::API_URL), + $payloadJson, + [ + 'Authorization' => 'DeepL-Auth-Key ' . $authKey, + 'Content-Type' => 'application/json', + ] + ); + + if ( + !is_object($response) + || !isset($response->translations) + || !is_array($response->translations) + || !isset($response->translations[0]->text) + || !is_string($response->translations[0]->text) + ) { + throw LangException::invalidProviderResponse('DeepL'); + } + + $translation = $response->translations[0]->text; + + $this->setCachedTranslation('deepl', $text, $translation); + + return $translation; + } + + public function flush(): void + { + } +} diff --git a/src/Lang/Adapters/FileAdapter.php b/src/Lang/Adapters/FileAdapter.php index ce31987e6..e13a21521 100644 --- a/src/Lang/Adapters/FileAdapter.php +++ b/src/Lang/Adapters/FileAdapter.php @@ -23,11 +23,20 @@ class FileAdapter implements LangAdapterInterface { protected string $lang; + /** + * @var array + */ + protected array $params = []; + private ?Data $translations = null; - public function __construct(string $lang) + /** + * @param array $params + */ + public function __construct(string $lang, array $params = []) { $this->lang = $lang; + $this->params = $params; } public function setLang(string $lang): LangAdapterInterface @@ -78,6 +87,10 @@ public function loadTranslations(): void */ public function get(string $key, array|string $params = null): string { + if ($this->translations === null) { + $this->loadTranslations(); + } + if ($this->translations && $this->translations->has($key)) { $message = $this->translations->get($key); return $params ? _message($message, $params) : $message; diff --git a/src/Lang/Adapters/GoogleTranslateAdapter.php b/src/Lang/Adapters/GoogleTranslateAdapter.php new file mode 100644 index 000000000..9f6ecea21 --- /dev/null +++ b/src/Lang/Adapters/GoogleTranslateAdapter.php @@ -0,0 +1,105 @@ + $params + */ + public function __construct(string $lang, array $params, ?HttpClient $httpClient = null) + { + $this->lang = $lang; + $this->params = $params; + $this->httpClient = $httpClient ?? new HttpClient(); + } + + public function setLang(string $lang): LangAdapterInterface + { + $this->lang = $lang; + return $this; + } + + /** + * @param array|string|null $params + */ + public function get(string $key, $params = null): string + { + $text = $this->buildSourceText($key, $params); + + if ($text === '') { + return $text; + } + + $cached = $this->getCachedTranslation('google_translate', $text); + + if ($cached !== null) { + return $cached; + } + + $apiKey = (string) ($this->params['api_key'] ?? ''); + + if ($apiKey === '') { + throw LangException::missingConfig('lang.google_translate.api_key'); + } + + $query = [ + 'q' => $text, + 'target' => $this->lang, + 'format' => 'text', + 'key' => $apiKey, + ]; + + if (!empty($this->params['source_locale'])) { + $query['source'] = (string) $this->params['source_locale']; + } + + $response = $this->sendRequest( + (string) ($this->params['api_url'] ?? self::API_URL) . '?' . http_build_query($query, '', '&'), + null, + [], + 'POST' + ); + + if ( + !is_object($response) + || !isset($response->data) + || !is_object($response->data) + || !isset($response->data->translations) + || !is_array($response->data->translations) + || !isset($response->data->translations[0]->translatedText) + || !is_string($response->data->translations[0]->translatedText) + ) { + throw LangException::invalidProviderResponse('Google Translate'); + } + + $translation = html_entity_decode($response->data->translations[0]->translatedText, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + $this->setCachedTranslation('google_translate', $text, $translation); + + return $translation; + } + + public function flush(): void + { + } +} diff --git a/src/Lang/Contracts/LangAdapterInterface.php b/src/Lang/Contracts/LangAdapterInterface.php index 32f30d53b..625624897 100644 --- a/src/Lang/Contracts/LangAdapterInterface.php +++ b/src/Lang/Contracts/LangAdapterInterface.php @@ -19,7 +19,5 @@ public function setLang(string $lang): LangAdapterInterface; */ public function get(string $key, array|string $params = null): string; - public function loadTranslations(): void; - public function flush(): void; } diff --git a/src/Lang/Enums/ExceptionMessages.php b/src/Lang/Enums/ExceptionMessages.php index a3e456ca5..605f4c08d 100644 --- a/src/Lang/Enums/ExceptionMessages.php +++ b/src/Lang/Enums/ExceptionMessages.php @@ -21,4 +21,8 @@ final class ExceptionMessages extends BaseExceptionMessages public const TRANSLATION_FILES_NOT_FOUND = 'Translation files not found.'; public const MISCONFIGURED_DEFAULT_LANG = 'Misconfigured lang default config.'; + + public const INVALID_PROVIDER_RESPONSE = 'The provider `{%1}` returned an invalid translation response.'; + + public const PROVIDER_REQUEST_FAILED = 'The translation request to `{%1}` failed{%2}.'; } diff --git a/src/Lang/Exceptions/LangException.php b/src/Lang/Exceptions/LangException.php index 219ece40a..fcbeff462 100644 --- a/src/Lang/Exceptions/LangException.php +++ b/src/Lang/Exceptions/LangException.php @@ -34,4 +34,22 @@ public static function misconfiguredDefaultConfig(): self E_WARNING ); } + + public static function invalidProviderResponse(string $provider): self + { + return new self( + _message(ExceptionMessages::INVALID_PROVIDER_RESPONSE, [$provider]), + E_WARNING + ); + } + + public static function providerRequestFailed(string $provider, ?string $details = null): self + { + $suffix = $details ? ': ' . $details : '.'; + + return new self( + _message(ExceptionMessages::PROVIDER_REQUEST_FAILED, [$provider, $suffix]), + E_WARNING + ); + } } diff --git a/src/Lang/Factories/LangFactory.php b/src/Lang/Factories/LangFactory.php index 025f48c07..822d5a96b 100644 --- a/src/Lang/Factories/LangFactory.php +++ b/src/Lang/Factories/LangFactory.php @@ -10,13 +10,16 @@ namespace Quantum\Lang\Factories; +use Quantum\Lang\Adapters\GoogleTranslateAdapter; use Quantum\Lang\Contracts\LangAdapterInterface; use Quantum\Loader\Exceptions\LoaderException; use Quantum\Config\Exceptions\ConfigException; use Quantum\Lang\Exceptions\LangException; use Quantum\App\Exceptions\BaseException; +use Quantum\Lang\Adapters\DeepLAdapter; use Quantum\Lang\Adapters\FileAdapter; use Quantum\Di\Exceptions\DiException; +use Quantum\HttpClient\HttpClient; use Quantum\Lang\Enums\LangType; use Quantum\Loader\Setup; use ReflectionException; @@ -31,6 +34,8 @@ class LangFactory { public const ADAPTERS = [ LangType::FILE => FileAdapter::class, + LangType::DEEPL => DeepLAdapter::class, + LangType::GOOGLE_TRANSLATE => GoogleTranslateAdapter::class, ]; /** @@ -68,7 +73,7 @@ public function resolve(?string $adapter = null): Lang if (!isset($this->instances[$adapter])) { $adapterClass = $this->getAdapterClass($adapter); - $this->instances[$adapter] = new Lang($lang, $isEnabled, $this->createAdapter($adapterClass, $lang, $adapter)); + $this->instances[$adapter] = $this->createInstance($adapterClass, $adapter, $lang, $isEnabled); } return $this->instances[$adapter]; @@ -87,17 +92,21 @@ private function getAdapterClass(string $adapter): string } /** - * @throws BaseException + * @throws BaseException|ReflectionException */ - private function createAdapter(string $adapterClass, string $lang, string $adapter): LangAdapterInterface + private function createInstance(string $adapterClass, string $adapter, string $lang, bool $isEnabled): Lang { - $langAdapter = new $adapterClass($lang); + $langConfig = (array) config()->get('lang.' . $adapter); + + $langAdapter = $adapter === LangType::FILE + ? new $adapterClass($lang, $langConfig) + : new $adapterClass($lang, $langConfig, new HttpClient()); if (!$langAdapter instanceof LangAdapterInterface) { throw LangException::adapterNotSupported($adapter); } - return $langAdapter; + return new Lang($lang, $isEnabled, $langAdapter); } /** diff --git a/src/Lang/Lang.php b/src/Lang/Lang.php index 3fe8174b9..c644a6a67 100644 --- a/src/Lang/Lang.php +++ b/src/Lang/Lang.php @@ -65,14 +65,6 @@ public function getAdapter(): LangAdapterInterface return $this->adapter; } - /** - * Load translations - */ - public function load(): void - { - $this->adapter->loadTranslations(); - } - /** * Get translation by key * @param string|array|null $params diff --git a/src/Lang/Traits/RemoteAdapterTrait.php b/src/Lang/Traits/RemoteAdapterTrait.php new file mode 100644 index 000000000..dc8754f7c --- /dev/null +++ b/src/Lang/Traits/RemoteAdapterTrait.php @@ -0,0 +1,134 @@ + + */ + protected array $params = []; + + /** + * @param array|string|null $params + */ + protected function buildSourceText(string $key, array|string $params = null): string + { + return $params ? _message($key, $params) : $key; + } + + /** + * @throws ConfigException|DiException|BaseException|ReflectionException + */ + protected function getCachedTranslation(string $adapter, string $text): ?string + { + if (!$this->isCacheEnabled()) { + return null; + } + + $cacheItem = cache($this->getCacheAdapter())->get($this->getCacheKey($adapter, $text)); + + return is_string($cacheItem) ? $cacheItem : null; + } + + /** + * @throws ConfigException|DiException|BaseException|ReflectionException + */ + protected function setCachedTranslation(string $adapter, string $text, string $translation): void + { + if (!$this->isCacheEnabled()) { + return; + } + + cache($this->getCacheAdapter())->set( + $this->getCacheKey($adapter, $text), + $translation, + $this->getCacheTtl() + ); + } + + /** + * @param array|string|null $data + * @param array $headers + * @param string $method + * @return mixed + * @throws BaseException + * @throws ErrorException + * @throws LangException + * @throws HttpClientException + */ + protected function sendRequest(string $url, $data = null, array $headers = [], string $method = 'POST'): mixed + { + $request = $this->httpClient + ->createRequest($url) + ->setMethod($method); + + if ($data !== null) { + $request->setData($data); + } + + if ($headers) { + $request->setHeaders($headers); + } + + $request->start(); + + $errors = $this->httpClient->getErrors(); + $responseBody = $this->httpClient->getResponseBody(); + + if ($errors) { + throw LangException::providerRequestFailed( + static::class, + json_encode($responseBody ?? $errors) ?: null + ); + } + + return $responseBody; + } + + private function isCacheEnabled(): bool + { + return filter_var($this->params['cache']['enabled'] ?? false, FILTER_VALIDATE_BOOLEAN); + } + + private function getCacheAdapter(): string + { + return (string) ($this->params['cache']['default'] ?? ''); + } + + private function getCacheTtl(): ?int + { + if (!isset($this->params['cache']['ttl'])) { + return null; + } + + return (int) $this->params['cache']['ttl']; + } + + private function getCacheKey(string $adapter, string $text): string + { + $prefix = (string) ($this->params['cache']['prefix'] ?? ''); + $sourceLocale = (string) ($this->params['source_locale'] ?? 'auto'); + + return $prefix . sha1($adapter . '|' . $this->lang . '|' . $sourceLocale . '|' . $text); + } +} diff --git a/tests/Unit/Lang/Helpers/LangHelperFunctionsTest.php b/tests/Unit/Lang/Helpers/LangHelperFunctionsTest.php index 7ad31bd2c..eb2797db2 100644 --- a/tests/Unit/Lang/Helpers/LangHelperFunctionsTest.php +++ b/tests/Unit/Lang/Helpers/LangHelperFunctionsTest.php @@ -15,8 +15,6 @@ public function setUp(): void parent::setUp(); $this->lang = LangFactory::get(); - - $this->lang->load(); } public function testLangHelperCurrentLang(): void diff --git a/tests/Unit/Lang/LangTest.php b/tests/Unit/Lang/LangTest.php index 9b82f6009..1e20a511f 100644 --- a/tests/Unit/Lang/LangTest.php +++ b/tests/Unit/Lang/LangTest.php @@ -54,10 +54,6 @@ public function testLangLoadAndGetTranslation(): void { $this->lang->flush(); - $this->assertEquals('custom.test', $this->lang->getTranslation('custom.test')); - - $this->lang->load(); - $this->assertEquals('Testing', $this->lang->getTranslation('custom.test')); $this->assertEquals('Information about the new feature', $this->lang->getTranslation('custom.info', ['new'])); @@ -65,12 +61,10 @@ public function testLangLoadAndGetTranslation(): void public function testLangFlushResetsTranslations(): void { - $this->lang->load(); - $this->assertEquals('Testing', $this->lang->getTranslation('custom.test')); $this->lang->flush(); - $this->assertEquals('test', $this->lang->getTranslation('test')); + $this->assertEquals('Testing', $this->lang->getTranslation('custom.test')); } } diff --git a/tests/_root/shared/config/lang.php b/tests/_root/shared/config/lang.php index 860008929..5ebef2db9 100644 --- a/tests/_root/shared/config/lang.php +++ b/tests/_root/shared/config/lang.php @@ -11,5 +11,25 @@ 'default' => 'file', 'default_locale' => 'en', 'file' => [], + 'deepl' => [ + 'auth_key' => '', + 'source_locale' => null, + 'cache' => [ + 'enabled' => true, + 'default' => 'file', + 'ttl' => 3600, + 'prefix' => 'lang_deepl:', + ], + ], + 'google_translate' => [ + 'api_key' => '', + 'source_locale' => null, + 'cache' => [ + 'enabled' => true, + 'default' => 'file', + 'ttl' => 3600, + 'prefix' => 'lang_google_translate:', + ], + ], 'url_segment' => 1, ]; From b4816f8b200da882b89888bd0fc24d676146a2ed Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:35:52 +0400 Subject: [PATCH 05/16] [#533] Add Lang adapter tests and remove dead enabled flag --- src/Lang/Factories/LangFactory.php | 7 +- src/Lang/Lang.php | 15 +-- tests/Unit/Lang/Adapters/DeepLAdapterTest.php | 105 +++++++++++++++++ .../Adapters/GoogleTranslateAdapterTest.php | 109 ++++++++++++++++++ tests/Unit/Lang/Factories/LangFactoryTest.php | 53 ++++++++- tests/Unit/Lang/LangTest.php | 11 +- tests/_root/shared/config/lang.php | 1 - 7 files changed, 268 insertions(+), 33 deletions(-) create mode 100644 tests/Unit/Lang/Adapters/DeepLAdapterTest.php create mode 100644 tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php diff --git a/src/Lang/Factories/LangFactory.php b/src/Lang/Factories/LangFactory.php index 822d5a96b..7d562858d 100644 --- a/src/Lang/Factories/LangFactory.php +++ b/src/Lang/Factories/LangFactory.php @@ -64,7 +64,6 @@ public function resolve(?string $adapter = null): Lang config()->import(new Setup('config', 'lang')); } - $isEnabled = filter_var(config()->get('lang.enabled'), FILTER_VALIDATE_BOOLEAN); $supported = (array) config()->get('lang.supported'); $default = config()->get('lang.default_locale'); $adapter ??= config()->get('lang.default'); @@ -73,7 +72,7 @@ public function resolve(?string $adapter = null): Lang if (!isset($this->instances[$adapter])) { $adapterClass = $this->getAdapterClass($adapter); - $this->instances[$adapter] = $this->createInstance($adapterClass, $adapter, $lang, $isEnabled); + $this->instances[$adapter] = $this->createInstance($adapterClass, $adapter, $lang); } return $this->instances[$adapter]; @@ -94,7 +93,7 @@ private function getAdapterClass(string $adapter): string /** * @throws BaseException|ReflectionException */ - private function createInstance(string $adapterClass, string $adapter, string $lang, bool $isEnabled): Lang + private function createInstance(string $adapterClass, string $adapter, string $lang): Lang { $langConfig = (array) config()->get('lang.' . $adapter); @@ -106,7 +105,7 @@ private function createInstance(string $adapterClass, string $adapter, string $l throw LangException::adapterNotSupported($adapter); } - return new Lang($lang, $isEnabled, $langAdapter); + return new Lang($lang, $langAdapter); } /** diff --git a/src/Lang/Lang.php b/src/Lang/Lang.php index c644a6a67..86aa9586b 100644 --- a/src/Lang/Lang.php +++ b/src/Lang/Lang.php @@ -11,8 +11,8 @@ namespace Quantum\Lang; use Quantum\Lang\Contracts\LangAdapterInterface; -use Quantum\App\Exceptions\BaseException; use Quantum\Lang\Exceptions\LangException; +use Quantum\App\Exceptions\BaseException; /** * Class Lang @@ -24,11 +24,8 @@ class Lang private LangAdapterInterface $adapter; - private bool $isEnabled; - - public function __construct(string $lang, bool $isEnabled, LangAdapterInterface $adapter) + public function __construct(string $lang, LangAdapterInterface $adapter) { - $this->isEnabled = $isEnabled; $this->adapter = $adapter; $this->setLang($lang); } @@ -52,14 +49,6 @@ public function getLang(): ?string return $this->currentLang; } - /** - * Is multilang enabled - */ - public function isEnabled(): bool - { - return $this->isEnabled; - } - public function getAdapter(): LangAdapterInterface { return $this->adapter; diff --git a/tests/Unit/Lang/Adapters/DeepLAdapterTest.php b/tests/Unit/Lang/Adapters/DeepLAdapterTest.php new file mode 100644 index 000000000..d67c294ba --- /dev/null +++ b/tests/Unit/Lang/Adapters/DeepLAdapterTest.php @@ -0,0 +1,105 @@ +cachePrefix = 'lang_deepl_test_' . uniqid('', true) . ':'; + } + + public function tearDown(): void + { + cache('file')->getAdapter()->clear(); + Mockery::close(); + + parent::tearDown(); + } + + public function testDeepLAdapterReturnsProviderTranslationAndCachesIt(): void + { + $this->currentResponse = (object) [ + 'translations' => [ + (object) ['text' => 'Hola'], + ], + ]; + + $adapter = new DeepLAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->assertEquals('Hola', $adapter->get('Hello')); + } + + public function testDeepLAdapterReturnsCachedTranslationWithoutProviderCall(): void + { + $this->currentResponse = (object) [ + 'translations' => [ + (object) ['text' => 'Hola'], + ], + ]; + + $firstAdapter = new DeepLAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->assertEquals('Hola', $firstAdapter->get('Hello')); + + $httpClientMock = Mockery::mock(HttpClient::class); + $httpClientMock->shouldNotReceive('createRequest'); + + $secondAdapter = new DeepLAdapter('es', $this->getParams(), $httpClientMock); + + $this->assertEquals('Hola', $secondAdapter->get('Hello')); + } + + public function testDeepLAdapterThrowsIfAuthKeyIsMissing(): void + { + $adapter = new DeepLAdapter('es', $this->getParams(['auth_key' => '']), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('Could not load config `lang.deepl.auth_key` properly.'); + + $adapter->get('Hello'); + } + + public function testDeepLAdapterThrowsIfProviderResponseIsInvalid(): void + { + $this->currentResponse = (object) []; + + $adapter = new DeepLAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('The provider `DeepL` returned an invalid translation response.'); + + $adapter->get('Hello'); + } + + /** + * @param array $overrides + * @return array + */ + private function getParams(array $overrides = []): array + { + return array_replace_recursive([ + 'auth_key' => 'test-auth-key', + 'source_locale' => 'en', + 'cache' => [ + 'enabled' => true, + 'default' => 'file', + 'ttl' => 3600, + 'prefix' => $this->cachePrefix, + ], + ], $overrides); + } +} diff --git a/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php b/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php new file mode 100644 index 000000000..d4073e19d --- /dev/null +++ b/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php @@ -0,0 +1,109 @@ +cachePrefix = 'lang_google_translate_test_' . uniqid('', true) . ':'; + } + + public function tearDown(): void + { + cache('file')->getAdapter()->clear(); + Mockery::close(); + + parent::tearDown(); + } + + public function testGoogleTranslateAdapterReturnsProviderTranslationAndCachesIt(): void + { + $this->currentResponse = (object) [ + 'data' => (object) [ + 'translations' => [ + (object) ['translatedText' => 'Hola'], + ], + ], + ]; + + $adapter = new GoogleTranslateAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->assertEquals('Hola', $adapter->get('Hello')); + } + + public function testGoogleTranslateAdapterReturnsCachedTranslationWithoutProviderCall(): void + { + $this->currentResponse = (object) [ + 'data' => (object) [ + 'translations' => [ + (object) ['translatedText' => 'Hola'], + ], + ], + ]; + + $firstAdapter = new GoogleTranslateAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->assertEquals('Hola', $firstAdapter->get('Hello')); + + $httpClientMock = Mockery::mock(HttpClient::class); + $httpClientMock->shouldNotReceive('createRequest'); + + $secondAdapter = new GoogleTranslateAdapter('es', $this->getParams(), $httpClientMock); + + $this->assertEquals('Hola', $secondAdapter->get('Hello')); + } + + public function testGoogleTranslateAdapterThrowsIfApiKeyIsMissing(): void + { + $adapter = new GoogleTranslateAdapter('es', $this->getParams(['api_key' => '']), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('Could not load config `lang.google_translate.api_key` properly.'); + + $adapter->get('Hello'); + } + + public function testGoogleTranslateAdapterThrowsIfProviderResponseIsInvalid(): void + { + $this->currentResponse = (object) []; + + $adapter = new GoogleTranslateAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('The provider `Google Translate` returned an invalid translation response.'); + + $adapter->get('Hello'); + } + + /** + * @param array $overrides + * @return array + */ + private function getParams(array $overrides = []): array + { + return array_replace_recursive([ + 'api_key' => 'test-api-key', + 'source_locale' => 'en', + 'cache' => [ + 'enabled' => true, + 'default' => 'file', + 'ttl' => 3600, + 'prefix' => $this->cachePrefix, + ], + ], $overrides); + } +} diff --git a/tests/Unit/Lang/Factories/LangFactoryTest.php b/tests/Unit/Lang/Factories/LangFactoryTest.php index d39173d91..dd7407ca7 100644 --- a/tests/Unit/Lang/Factories/LangFactoryTest.php +++ b/tests/Unit/Lang/Factories/LangFactoryTest.php @@ -2,8 +2,10 @@ namespace Quantum\Tests\Unit\Lang\Factories; +use Quantum\Lang\Adapters\GoogleTranslateAdapter; use Quantum\Lang\Exceptions\LangException; use Quantum\Lang\Factories\LangFactory; +use Quantum\Lang\Adapters\DeepLAdapter; use Quantum\Lang\Adapters\FileAdapter; use Quantum\Tests\Unit\AppTestCase; use Quantum\Lang\Lang; @@ -25,8 +27,6 @@ public function testLangFactoryGetLangInstance(): void $this->assertInstanceOf(Lang::class, $lang); $this->assertEquals('en', $lang->getLang()); - - $this->assertTrue($lang->isEnabled()); } public function testLangFactoryGetReturnsSameInstance(): void @@ -45,6 +45,52 @@ public function testLangFactoryResolvesFileAdapter(): void $this->assertInstanceOf(FileAdapter::class, $lang->getAdapter()); } + public function testLangFactoryResolvesDeepLAdapter(): void + { + config()->set('lang', [ + 'default' => 'deepl', + 'default_locale' => 'en', + 'file' => [], + 'deepl' => [ + 'auth_key' => 'test-auth-key', + ], + 'google_translate' => [ + 'api_key' => 'test-api-key', + ], + 'supported' => ['en', 'es'], + 'url_segment' => 1, + ]); + + $this->testRequest('http://127.0.0.1/api/rest'); + + $lang = LangFactory::get(); + + $this->assertInstanceOf(DeepLAdapter::class, $lang->getAdapter()); + } + + public function testLangFactoryResolvesGoogleTranslateAdapter(): void + { + config()->set('lang', [ + 'default' => 'google_translate', + 'default_locale' => 'en', + 'file' => [], + 'deepl' => [ + 'auth_key' => 'test-auth-key', + ], + 'google_translate' => [ + 'api_key' => 'test-api-key', + ], + 'supported' => ['en', 'es'], + 'url_segment' => 1, + ]); + + $this->testRequest('http://127.0.0.1/api/rest'); + + $lang = LangFactory::get(); + + $this->assertInstanceOf(GoogleTranslateAdapter::class, $lang->getAdapter()); + } + public function testLangFactoryDetectedFromRouteParameter(): void { $this->testRequest('http://127.0.0.1/es/api/rest'); @@ -84,7 +130,6 @@ public function testLangFactoryFallsBackToDefaultIfNoLangDetected(): void public function testLangFactoryFallsBackToDefaultIfProvidedLangIsNotSupported(): void { config()->set('lang', [ - 'enabled' => true, 'default' => 'file', 'default_locale' => 'en', 'file' => [], @@ -118,7 +163,6 @@ public function testLangFactoryFallsBackToDefaultIfProvidedLangIsNotSupported(): public function testLangFactoryThrowsErrorIfNoDefaultLangFound(): void { config()->set('lang', [ - 'enabled' => true, 'default' => 'file', 'default_locale' => null, 'file' => [], @@ -138,7 +182,6 @@ public function testLangFactoryThrowsErrorIfNoDefaultLangFound(): void public function testLangFactoryThrowsErrorIfAdapterIsNotSupported(): void { config()->set('lang', [ - 'enabled' => true, 'default' => 'unknown', 'default_locale' => 'en', 'file' => [], diff --git a/tests/Unit/Lang/LangTest.php b/tests/Unit/Lang/LangTest.php index 1e20a511f..cfc112e21 100644 --- a/tests/Unit/Lang/LangTest.php +++ b/tests/Unit/Lang/LangTest.php @@ -16,7 +16,7 @@ public function setUp(): void { parent::setUp(); - $this->lang = new Lang('en', true, new FileAdapter('en')); + $this->lang = new Lang('en', new FileAdapter('en')); $route = new Route( ['POST'], @@ -41,15 +41,6 @@ public function testLangGetSet(): void $this->assertEquals('ru', $this->lang->getLang()); } - public function testLangIsEnabled(): void - { - $this->assertTrue($this->lang->isEnabled()); - - $langDisabled = new Lang('en', false, new FileAdapter('en')); - - $this->assertFalse($langDisabled->isEnabled()); - } - public function testLangLoadAndGetTranslation(): void { $this->lang->flush(); diff --git a/tests/_root/shared/config/lang.php b/tests/_root/shared/config/lang.php index 5ebef2db9..c55ff4f9f 100644 --- a/tests/_root/shared/config/lang.php +++ b/tests/_root/shared/config/lang.php @@ -6,7 +6,6 @@ * Multilingual settings * --------------------------------------------------------- */ - 'enabled' => true, 'supported' => ['en', 'es'], 'default' => 'file', 'default_locale' => 'en', From f5d2e64c17d9e1771ee3668ee4dedcb02d3b21d9 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:38:34 +0400 Subject: [PATCH 06/16] [#533] Record Lang breaking changes in changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e752704a..51258dfe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ The format is based on Keep a Changelog. ### Changed - Refactored the Lang package to resolve adapter instances through `LangFactory` configuration and load file translations lazily on first use instead of preloading them during web boot (#533) +- **BREAKING:** Reshaped Lang configuration so `lang.default` now selects the adapter, locale fallback moved to `lang.default_locale`, and the unused `lang.enabled` toggle was removed (#533) +- **BREAKING:** Removed `Lang::isEnabled()` from the public Lang API because it no longer affected runtime behavior (#533) ## [3.0.3] - 2026-07-10 From 0b4c949b93af1c228f39bc1e1e1728ef834a575c Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:09:04 +0400 Subject: [PATCH 07/16] [#533] Refine Lang remote adapter request and contract --- src/Lang/Adapters/DeepLAdapter.php | 8 +++--- src/Lang/Adapters/GoogleTranslateAdapter.php | 27 ++++++++++++------- src/Lang/Contracts/LangAdapterInterface.php | 2 -- src/Lang/Lang.php | 8 ------ .../Adapters/GoogleTranslateAdapterTest.php | 11 ++++++++ tests/Unit/Storage/HttpClientTestCase.php | 9 +++++-- 6 files changed, 39 insertions(+), 26 deletions(-) diff --git a/src/Lang/Adapters/DeepLAdapter.php b/src/Lang/Adapters/DeepLAdapter.php index 469b5074c..9c9cf2efa 100644 --- a/src/Lang/Adapters/DeepLAdapter.php +++ b/src/Lang/Adapters/DeepLAdapter.php @@ -13,7 +13,10 @@ use Quantum\Lang\Contracts\LangAdapterInterface; use Quantum\Lang\Traits\RemoteAdapterTrait; use Quantum\Lang\Exceptions\LangException; +use Quantum\App\Exceptions\BaseException; use Quantum\HttpClient\HttpClient; +use ReflectionException; +use ErrorException; class DeepLAdapter implements LangAdapterInterface { @@ -41,6 +44,7 @@ public function setLang(string $lang): LangAdapterInterface /** * @param array|string|null $params + * @throws BaseException|ReflectionException|ErrorException */ public function get(string $key, $params = null): string { @@ -102,8 +106,4 @@ public function get(string $key, $params = null): string return $translation; } - - public function flush(): void - { - } } diff --git a/src/Lang/Adapters/GoogleTranslateAdapter.php b/src/Lang/Adapters/GoogleTranslateAdapter.php index 9f6ecea21..86886231d 100644 --- a/src/Lang/Adapters/GoogleTranslateAdapter.php +++ b/src/Lang/Adapters/GoogleTranslateAdapter.php @@ -13,7 +13,10 @@ use Quantum\Lang\Contracts\LangAdapterInterface; use Quantum\Lang\Traits\RemoteAdapterTrait; use Quantum\Lang\Exceptions\LangException; +use Quantum\App\Exceptions\BaseException; use Quantum\HttpClient\HttpClient; +use ReflectionException; +use ErrorException; class GoogleTranslateAdapter implements LangAdapterInterface { @@ -41,6 +44,7 @@ public function setLang(string $lang): LangAdapterInterface /** * @param array|string|null $params + * @throws LangException|BaseException|ReflectionException|ErrorException */ public function get(string $key, $params = null): string { @@ -62,21 +66,28 @@ public function get(string $key, $params = null): string throw LangException::missingConfig('lang.google_translate.api_key'); } - $query = [ + $payload = [ 'q' => $text, 'target' => $this->lang, 'format' => 'text', - 'key' => $apiKey, ]; if (!empty($this->params['source_locale'])) { - $query['source'] = (string) $this->params['source_locale']; + $payload['source'] = (string) $this->params['source_locale']; + } + + $payloadJson = json_encode($payload); + + if ($payloadJson === false) { + throw LangException::invalidProviderResponse('Google Translate'); } $response = $this->sendRequest( - (string) ($this->params['api_url'] ?? self::API_URL) . '?' . http_build_query($query, '', '&'), - null, - [], + (string) ($this->params['api_url'] ?? self::API_URL) . '?key=' . urlencode($apiKey), + $payloadJson, + [ + 'Content-Type' => 'application/json', + ], 'POST' ); @@ -98,8 +109,4 @@ public function get(string $key, $params = null): string return $translation; } - - public function flush(): void - { - } } diff --git a/src/Lang/Contracts/LangAdapterInterface.php b/src/Lang/Contracts/LangAdapterInterface.php index 625624897..d845eb1fd 100644 --- a/src/Lang/Contracts/LangAdapterInterface.php +++ b/src/Lang/Contracts/LangAdapterInterface.php @@ -18,6 +18,4 @@ public function setLang(string $lang): LangAdapterInterface; * @param string|array|null $params */ public function get(string $key, array|string $params = null): string; - - public function flush(): void; } diff --git a/src/Lang/Lang.php b/src/Lang/Lang.php index 86aa9586b..dc566c1b3 100644 --- a/src/Lang/Lang.php +++ b/src/Lang/Lang.php @@ -63,14 +63,6 @@ public function getTranslation(string $key, array|string $params = null): ?strin return $this->adapter->get($key, $params); } - /** - * Flush loaded translations - */ - public function flush(): void - { - $this->adapter->flush(); - } - /** * @param array $arguments * @return mixed diff --git a/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php b/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php index d4073e19d..d13a4b92c 100644 --- a/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php +++ b/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php @@ -43,6 +43,17 @@ public function testGoogleTranslateAdapterReturnsProviderTranslationAndCachesIt( $adapter = new GoogleTranslateAdapter('es', $this->getParams(), $this->mockHttpClient()); $this->assertEquals('Hola', $adapter->get('Hello')); + $this->assertStringContainsString('?key=test-api-key', $this->url); + $this->assertStringNotContainsString('q=', $this->url); + $this->assertJsonStringEqualsJsonString( + json_encode([ + 'q' => 'Hello', + 'target' => 'es', + 'format' => 'text', + 'source' => 'en', + ]), + (string) $this->data[$this->url] + ); } public function testGoogleTranslateAdapterReturnsCachedTranslationWithoutProviderCall(): void diff --git a/tests/Unit/Storage/HttpClientTestCase.php b/tests/Unit/Storage/HttpClientTestCase.php index ae1cf845f..e3a48313e 100644 --- a/tests/Unit/Storage/HttpClientTestCase.php +++ b/tests/Unit/Storage/HttpClientTestCase.php @@ -15,6 +15,8 @@ trait HttpClientTestCase protected $currentErrors; + protected $data; + protected function mockHttpClient() { $httpClientMock = Mockery::mock(HttpClient::class); @@ -30,9 +32,12 @@ protected function mockHttpClient() $httpClientMock->shouldReceive('getRequestHeaders')->andReturn([]); - $httpClientMock->shouldReceive('getData')->andReturn([]); + $httpClientMock->shouldReceive('getData')->andReturnUsing(fn () => $this->data[$this->url] ?? []); - $httpClientMock->shouldReceive('setData')->andReturn($httpClientMock); + $httpClientMock->shouldReceive('setData')->andReturnUsing(function ($data) use ($httpClientMock) { + $this->data[$this->url] = $data; + return $httpClientMock; + }); $httpClientMock->shouldReceive('start')->andReturnUsing(function () use ($httpClientMock) { $this->response[$this->url]['body'] = $this->currentResponse; From 1ab98c5526f902fe44bf5d2e10d8c418f06cb287 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:17:44 +0400 Subject: [PATCH 08/16] [#533] Fix Lang provider request failure punctuation --- src/Lang/Exceptions/LangException.php | 2 +- .../Unit/Lang/Exceptions/LangExceptionTest.php | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Lang/Exceptions/LangException.php b/src/Lang/Exceptions/LangException.php index fcbeff462..cd1a020b9 100644 --- a/src/Lang/Exceptions/LangException.php +++ b/src/Lang/Exceptions/LangException.php @@ -45,7 +45,7 @@ public static function invalidProviderResponse(string $provider): self public static function providerRequestFailed(string $provider, ?string $details = null): self { - $suffix = $details ? ': ' . $details : '.'; + $suffix = $details ? ': ' . $details : ''; return new self( _message(ExceptionMessages::PROVIDER_REQUEST_FAILED, [$provider, $suffix]), diff --git a/tests/Unit/Lang/Exceptions/LangExceptionTest.php b/tests/Unit/Lang/Exceptions/LangExceptionTest.php index 62113ac5b..016247bce 100644 --- a/tests/Unit/Lang/Exceptions/LangExceptionTest.php +++ b/tests/Unit/Lang/Exceptions/LangExceptionTest.php @@ -24,4 +24,22 @@ public function testMisconfiguredDefaultConfig(): void $this->assertSame('Misconfigured lang default config.', $exception->getMessage()); $this->assertSame(E_WARNING, $exception->getCode()); } + + public function testProviderRequestFailedWithoutDetails(): void + { + $exception = LangException::providerRequestFailed('Google Translate'); + + $this->assertInstanceOf(LangException::class, $exception); + $this->assertSame('The translation request to `Google Translate` failed.', $exception->getMessage()); + $this->assertSame(E_WARNING, $exception->getCode()); + } + + public function testProviderRequestFailedWithDetails(): void + { + $exception = LangException::providerRequestFailed('Google Translate', 'timeout'); + + $this->assertInstanceOf(LangException::class, $exception); + $this->assertSame('The translation request to `Google Translate` failed: timeout.', $exception->getMessage()); + $this->assertSame(E_WARNING, $exception->getCode()); + } } From d7950072dead5970cdec7e623a08466639355338 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:22:54 +0400 Subject: [PATCH 09/16] [#533] Guard missing Lang default adapter config --- src/Lang/Enums/ExceptionMessages.php | 2 ++ src/Lang/Exceptions/LangException.php | 8 ++++++++ src/Lang/Factories/LangFactory.php | 4 ++++ tests/Unit/Lang/Factories/LangFactoryTest.php | 16 ++++++++++++++++ 4 files changed, 30 insertions(+) diff --git a/src/Lang/Enums/ExceptionMessages.php b/src/Lang/Enums/ExceptionMessages.php index 605f4c08d..59de87094 100644 --- a/src/Lang/Enums/ExceptionMessages.php +++ b/src/Lang/Enums/ExceptionMessages.php @@ -22,6 +22,8 @@ final class ExceptionMessages extends BaseExceptionMessages public const MISCONFIGURED_DEFAULT_LANG = 'Misconfigured lang default config.'; + public const MISCONFIGURED_DEFAULT_ADAPTER = 'Misconfigured lang default adapter config.'; + public const INVALID_PROVIDER_RESPONSE = 'The provider `{%1}` returned an invalid translation response.'; public const PROVIDER_REQUEST_FAILED = 'The translation request to `{%1}` failed{%2}.'; diff --git a/src/Lang/Exceptions/LangException.php b/src/Lang/Exceptions/LangException.php index cd1a020b9..31875a643 100644 --- a/src/Lang/Exceptions/LangException.php +++ b/src/Lang/Exceptions/LangException.php @@ -35,6 +35,14 @@ public static function misconfiguredDefaultConfig(): self ); } + public static function misconfiguredDefaultAdapterConfig(): self + { + return new self( + ExceptionMessages::MISCONFIGURED_DEFAULT_ADAPTER, + E_WARNING + ); + } + public static function invalidProviderResponse(string $provider): self { return new self( diff --git a/src/Lang/Factories/LangFactory.php b/src/Lang/Factories/LangFactory.php index 7d562858d..a0eec250a 100644 --- a/src/Lang/Factories/LangFactory.php +++ b/src/Lang/Factories/LangFactory.php @@ -68,6 +68,10 @@ public function resolve(?string $adapter = null): Lang $default = config()->get('lang.default_locale'); $adapter ??= config()->get('lang.default'); + if (!$adapter) { + throw LangException::misconfiguredDefaultAdapterConfig(); + } + $lang = $this->detectLanguage($supported, $default); if (!isset($this->instances[$adapter])) { diff --git a/tests/Unit/Lang/Factories/LangFactoryTest.php b/tests/Unit/Lang/Factories/LangFactoryTest.php index dd7407ca7..422844e13 100644 --- a/tests/Unit/Lang/Factories/LangFactoryTest.php +++ b/tests/Unit/Lang/Factories/LangFactoryTest.php @@ -195,6 +195,22 @@ public function testLangFactoryThrowsErrorIfAdapterIsNotSupported(): void LangFactory::get(); } + public function testLangFactoryThrowsErrorIfNoDefaultAdapterFound(): void + { + config()->set('lang', [ + 'default' => null, + 'default_locale' => 'en', + 'file' => [], + 'supported' => ['en', 'es'], + 'url_segment' => 1, + ]); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('Misconfigured lang default adapter config.'); + + LangFactory::get(); + } + private function resetLangFactory(): void { if (!Di::isRegistered(LangFactory::class)) { From 915a7f8f638405563fcce232e40252785b41ac9f Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:36:16 +0400 Subject: [PATCH 10/16] [#533] Simplify Lang remote adapter translation flow --- src/Lang/Adapters/DeepLAdapter.php | 43 ++++++++++++++------ src/Lang/Adapters/GoogleTranslateAdapter.php | 43 ++++++++++++++------ 2 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/Lang/Adapters/DeepLAdapter.php b/src/Lang/Adapters/DeepLAdapter.php index 9c9cf2efa..8051e54c0 100644 --- a/src/Lang/Adapters/DeepLAdapter.php +++ b/src/Lang/Adapters/DeepLAdapter.php @@ -66,6 +66,27 @@ public function get(string $key, $params = null): string throw LangException::missingConfig('lang.deepl.auth_key'); } + $response = $this->sendRequest( + (string) ($this->params['api_url'] ?? self::API_URL), + $this->buildPayload($text), + [ + 'Authorization' => 'DeepL-Auth-Key ' . $authKey, + 'Content-Type' => 'application/json', + ] + ); + + $translation = $this->extractTranslation($response); + + $this->setCachedTranslation('deepl', $text, $translation); + + return $translation; + } + + /** + * @throws LangException + */ + private function buildPayload(string $text): string + { $payload = [ 'text' => [$text], 'target_lang' => strtoupper($this->lang), @@ -81,15 +102,15 @@ public function get(string $key, $params = null): string throw LangException::invalidProviderResponse('DeepL'); } - $response = $this->sendRequest( - (string) ($this->params['api_url'] ?? self::API_URL), - $payloadJson, - [ - 'Authorization' => 'DeepL-Auth-Key ' . $authKey, - 'Content-Type' => 'application/json', - ] - ); + return $payloadJson; + } + /** + * @param mixed $response + * @throws LangException + */ + private function extractTranslation($response): string + { if ( !is_object($response) || !isset($response->translations) @@ -100,10 +121,6 @@ public function get(string $key, $params = null): string throw LangException::invalidProviderResponse('DeepL'); } - $translation = $response->translations[0]->text; - - $this->setCachedTranslation('deepl', $text, $translation); - - return $translation; + return $response->translations[0]->text; } } diff --git a/src/Lang/Adapters/GoogleTranslateAdapter.php b/src/Lang/Adapters/GoogleTranslateAdapter.php index 86886231d..a2318b11c 100644 --- a/src/Lang/Adapters/GoogleTranslateAdapter.php +++ b/src/Lang/Adapters/GoogleTranslateAdapter.php @@ -66,6 +66,27 @@ public function get(string $key, $params = null): string throw LangException::missingConfig('lang.google_translate.api_key'); } + $response = $this->sendRequest( + (string) ($this->params['api_url'] ?? self::API_URL) . '?key=' . urlencode($apiKey), + $this->buildPayload($text), + [ + 'Content-Type' => 'application/json', + ], + 'POST' + ); + + $translation = $this->extractTranslation($response); + + $this->setCachedTranslation('google_translate', $text, $translation); + + return $translation; + } + + /** + * @throws LangException + */ + private function buildPayload(string $text): string + { $payload = [ 'q' => $text, 'target' => $this->lang, @@ -82,15 +103,15 @@ public function get(string $key, $params = null): string throw LangException::invalidProviderResponse('Google Translate'); } - $response = $this->sendRequest( - (string) ($this->params['api_url'] ?? self::API_URL) . '?key=' . urlencode($apiKey), - $payloadJson, - [ - 'Content-Type' => 'application/json', - ], - 'POST' - ); + return $payloadJson; + } + /** + * @param mixed $response + * @throws LangException + */ + private function extractTranslation($response): string + { if ( !is_object($response) || !isset($response->data) @@ -103,10 +124,6 @@ public function get(string $key, $params = null): string throw LangException::invalidProviderResponse('Google Translate'); } - $translation = html_entity_decode($response->data->translations[0]->translatedText, ENT_QUOTES | ENT_HTML5, 'UTF-8'); - - $this->setCachedTranslation('google_translate', $text, $translation); - - return $translation; + return html_entity_decode($response->data->translations[0]->translatedText, ENT_QUOTES | ENT_HTML5, 'UTF-8'); } } From 223e042475c008576075b477e29ac595d03a6f35 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:45:35 +0400 Subject: [PATCH 11/16] [#533] Expand Lang coverage for adapter edge cases --- tests/Unit/Lang/Adapters/DeepLAdapterTest.php | 30 ++++++++++++++++--- tests/Unit/Lang/Adapters/FileAdapterTest.php | 10 +++++++ .../Adapters/GoogleTranslateAdapterTest.php | 28 +++++++++++++++-- .../Lang/Exceptions/LangExceptionTest.php | 18 +++++++++++ tests/Unit/Lang/LangTest.php | 21 +++++++++++++ 5 files changed, 100 insertions(+), 7 deletions(-) diff --git a/tests/Unit/Lang/Adapters/DeepLAdapterTest.php b/tests/Unit/Lang/Adapters/DeepLAdapterTest.php index d67c294ba..e722a3f3c 100644 --- a/tests/Unit/Lang/Adapters/DeepLAdapterTest.php +++ b/tests/Unit/Lang/Adapters/DeepLAdapterTest.php @@ -2,12 +2,12 @@ namespace Quantum\Tests\Unit\Lang\Adapters; -use Mockery; -use Quantum\HttpClient\HttpClient; -use Quantum\Lang\Adapters\DeepLAdapter; +use Quantum\Tests\Unit\Storage\HttpClientTestCase; use Quantum\Lang\Exceptions\LangException; +use Quantum\Lang\Adapters\DeepLAdapter; use Quantum\Tests\Unit\AppTestCase; -use Quantum\Tests\Unit\Storage\HttpClientTestCase; +use Quantum\HttpClient\HttpClient; +use Mockery; class DeepLAdapterTest extends AppTestCase { @@ -85,6 +85,28 @@ public function testDeepLAdapterThrowsIfProviderResponseIsInvalid(): void $adapter->get('Hello'); } + public function testDeepLAdapterReturnsEmptyStringWithoutProviderCall(): void + { + $httpClientMock = Mockery::mock(HttpClient::class); + $httpClientMock->shouldNotReceive('createRequest'); + + $adapter = new DeepLAdapter('es', $this->getParams(), $httpClientMock); + + $this->assertSame('', $adapter->get('')); + } + + public function testDeepLAdapterThrowsIfProviderRequestFails(): void + { + $this->currentErrors = ['timeout']; + + $adapter = new DeepLAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('The translation request to `Quantum\\Lang\\Adapters\\DeepLAdapter` failed'); + + $adapter->get('Hello'); + } + /** * @param array $overrides * @return array diff --git a/tests/Unit/Lang/Adapters/FileAdapterTest.php b/tests/Unit/Lang/Adapters/FileAdapterTest.php index 04a9aced2..c4e395e59 100644 --- a/tests/Unit/Lang/Adapters/FileAdapterTest.php +++ b/tests/Unit/Lang/Adapters/FileAdapterTest.php @@ -35,4 +35,14 @@ public function testFileAdapterGetTranslation(): void $this->assertEquals('Information about the new feature', $result); } + + public function testFileAdapterSetLangKeepsTranslationsWhenLangDoesNotChange(): void + { + $adapter = new FileAdapter('en'); + + $adapter->loadTranslations(); + $adapter->setLang('en'); + + $this->assertEquals('Testing', $adapter->get('custom.test')); + } } diff --git a/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php b/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php index d13a4b92c..ff0d2fff4 100644 --- a/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php +++ b/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php @@ -2,12 +2,12 @@ namespace Quantum\Tests\Unit\Lang\Adapters; -use Mockery; -use Quantum\HttpClient\HttpClient; +use Quantum\Tests\Unit\Storage\HttpClientTestCase; use Quantum\Lang\Adapters\GoogleTranslateAdapter; use Quantum\Lang\Exceptions\LangException; +use Quantum\HttpClient\HttpClient; use Quantum\Tests\Unit\AppTestCase; -use Quantum\Tests\Unit\Storage\HttpClientTestCase; +use Mockery; class GoogleTranslateAdapterTest extends AppTestCase { @@ -100,6 +100,28 @@ public function testGoogleTranslateAdapterThrowsIfProviderResponseIsInvalid(): v $adapter->get('Hello'); } + public function testGoogleTranslateAdapterReturnsEmptyStringWithoutProviderCall(): void + { + $httpClientMock = Mockery::mock(HttpClient::class); + $httpClientMock->shouldNotReceive('createRequest'); + + $adapter = new GoogleTranslateAdapter('es', $this->getParams(), $httpClientMock); + + $this->assertSame('', $adapter->get('')); + } + + public function testGoogleTranslateAdapterThrowsIfProviderRequestFails(): void + { + $this->currentErrors = ['timeout']; + + $adapter = new GoogleTranslateAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('The translation request to `Quantum\\Lang\\Adapters\\GoogleTranslateAdapter` failed'); + + $adapter->get('Hello'); + } + /** * @param array $overrides * @return array diff --git a/tests/Unit/Lang/Exceptions/LangExceptionTest.php b/tests/Unit/Lang/Exceptions/LangExceptionTest.php index 016247bce..8bb5dfb85 100644 --- a/tests/Unit/Lang/Exceptions/LangExceptionTest.php +++ b/tests/Unit/Lang/Exceptions/LangExceptionTest.php @@ -25,6 +25,24 @@ public function testMisconfiguredDefaultConfig(): void $this->assertSame(E_WARNING, $exception->getCode()); } + public function testMisconfiguredDefaultAdapterConfig(): void + { + $exception = LangException::misconfiguredDefaultAdapterConfig(); + + $this->assertInstanceOf(LangException::class, $exception); + $this->assertSame('Misconfigured lang default adapter config.', $exception->getMessage()); + $this->assertSame(E_WARNING, $exception->getCode()); + } + + public function testInvalidProviderResponse(): void + { + $exception = LangException::invalidProviderResponse('DeepL'); + + $this->assertInstanceOf(LangException::class, $exception); + $this->assertSame('The provider `DeepL` returned an invalid translation response.', $exception->getMessage()); + $this->assertSame(E_WARNING, $exception->getCode()); + } + public function testProviderRequestFailedWithoutDetails(): void { $exception = LangException::providerRequestFailed('Google Translate'); diff --git a/tests/Unit/Lang/LangTest.php b/tests/Unit/Lang/LangTest.php index cfc112e21..d6eaf2d30 100644 --- a/tests/Unit/Lang/LangTest.php +++ b/tests/Unit/Lang/LangTest.php @@ -2,6 +2,7 @@ namespace Quantum\Tests\Unit\Lang; +use Quantum\Lang\Exceptions\LangException; use Quantum\Lang\Adapters\FileAdapter; use Quantum\Tests\Unit\AppTestCase; use Quantum\Router\MatchedRoute; @@ -58,4 +59,24 @@ public function testLangFlushResetsTranslations(): void $this->assertEquals('Testing', $this->lang->getTranslation('custom.test')); } + + public function testLangGetAdapter(): void + { + $this->assertInstanceOf(FileAdapter::class, $this->lang->getAdapter()); + } + + public function testLangCallForwardsToAdapterMethod(): void + { + $this->lang->flush(); + + $this->assertEquals('Testing', $this->lang->get('custom.test')); + } + + public function testLangCallThrowsForUnsupportedMethod(): void + { + $this->expectException(LangException::class); + $this->expectExceptionMessage('The method `nonExistingMethod` is not supported for `' . FileAdapter::class . '`.'); + + $this->lang->nonExistingMethod(); + } } From 11b5b9eed35fa27d3b02dceb4d5214fc1807e88e Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:56:51 +0400 Subject: [PATCH 12/16] [#533] Add DeepL request timeout --- src/Lang/Adapters/DeepLAdapter.php | 5 +++++ src/Lang/Traits/RemoteAdapterTrait.php | 7 ++++++- tests/Unit/Lang/Adapters/DeepLAdapterTest.php | 1 + tests/Unit/Storage/HttpClientTestCase.php | 7 +++++++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Lang/Adapters/DeepLAdapter.php b/src/Lang/Adapters/DeepLAdapter.php index 8051e54c0..39b9e210e 100644 --- a/src/Lang/Adapters/DeepLAdapter.php +++ b/src/Lang/Adapters/DeepLAdapter.php @@ -24,6 +24,8 @@ class DeepLAdapter implements LangAdapterInterface public const API_URL = 'https://api.deepl.com/v2/translate'; + public const TIMEOUT = 30; + protected string $lang; /** @@ -72,6 +74,9 @@ public function get(string $key, $params = null): string [ 'Authorization' => 'DeepL-Auth-Key ' . $authKey, 'Content-Type' => 'application/json', + ], + [ + CURLOPT_TIMEOUT => self::TIMEOUT, ] ); diff --git a/src/Lang/Traits/RemoteAdapterTrait.php b/src/Lang/Traits/RemoteAdapterTrait.php index dc8754f7c..baa9b6cc1 100644 --- a/src/Lang/Traits/RemoteAdapterTrait.php +++ b/src/Lang/Traits/RemoteAdapterTrait.php @@ -69,6 +69,7 @@ protected function setCachedTranslation(string $adapter, string $text, string $t /** * @param array|string|null $data * @param array $headers + * @param array $options * @param string $method * @return mixed * @throws BaseException @@ -76,7 +77,7 @@ protected function setCachedTranslation(string $adapter, string $text, string $t * @throws LangException * @throws HttpClientException */ - protected function sendRequest(string $url, $data = null, array $headers = [], string $method = 'POST'): mixed + protected function sendRequest(string $url, $data = null, array $headers = [], array $options = [], string $method = 'POST'): mixed { $request = $this->httpClient ->createRequest($url) @@ -90,6 +91,10 @@ protected function sendRequest(string $url, $data = null, array $headers = [], s $request->setHeaders($headers); } + foreach ($options as $option => $value) { + $request->setOpt($option, $value); + } + $request->start(); $errors = $this->httpClient->getErrors(); diff --git a/tests/Unit/Lang/Adapters/DeepLAdapterTest.php b/tests/Unit/Lang/Adapters/DeepLAdapterTest.php index e722a3f3c..7a7178269 100644 --- a/tests/Unit/Lang/Adapters/DeepLAdapterTest.php +++ b/tests/Unit/Lang/Adapters/DeepLAdapterTest.php @@ -41,6 +41,7 @@ public function testDeepLAdapterReturnsProviderTranslationAndCachesIt(): void $adapter = new DeepLAdapter('es', $this->getParams(), $this->mockHttpClient()); $this->assertEquals('Hola', $adapter->get('Hello')); + $this->assertSame(DeepLAdapter::TIMEOUT, $this->options[$this->url][CURLOPT_TIMEOUT]); } public function testDeepLAdapterReturnsCachedTranslationWithoutProviderCall(): void diff --git a/tests/Unit/Storage/HttpClientTestCase.php b/tests/Unit/Storage/HttpClientTestCase.php index e3a48313e..184fc8810 100644 --- a/tests/Unit/Storage/HttpClientTestCase.php +++ b/tests/Unit/Storage/HttpClientTestCase.php @@ -17,6 +17,8 @@ trait HttpClientTestCase protected $data; + protected $options; + protected function mockHttpClient() { $httpClientMock = Mockery::mock(HttpClient::class); @@ -30,6 +32,11 @@ protected function mockHttpClient() $httpClientMock->shouldReceive('setHeaders')->andReturnSelf(); + $httpClientMock->shouldReceive('setOpt')->andReturnUsing(function ($option, $value) use ($httpClientMock) { + $this->options[$this->url][$option] = $value; + return $httpClientMock; + }); + $httpClientMock->shouldReceive('getRequestHeaders')->andReturn([]); $httpClientMock->shouldReceive('getData')->andReturnUsing(fn () => $this->data[$this->url] ?? []); From fdd63f90296b7aa1e42c3d7ee2fb9ed992ef71af Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sun, 12 Jul 2026 01:04:45 +0400 Subject: [PATCH 13/16] [#533] Normalize Google adapter request call --- src/Lang/Adapters/GoogleTranslateAdapter.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Lang/Adapters/GoogleTranslateAdapter.php b/src/Lang/Adapters/GoogleTranslateAdapter.php index a2318b11c..411455199 100644 --- a/src/Lang/Adapters/GoogleTranslateAdapter.php +++ b/src/Lang/Adapters/GoogleTranslateAdapter.php @@ -72,7 +72,6 @@ public function get(string $key, $params = null): string [ 'Content-Type' => 'application/json', ], - 'POST' ); $translation = $this->extractTranslation($response); @@ -100,7 +99,7 @@ private function buildPayload(string $text): string $payloadJson = json_encode($payload); if ($payloadJson === false) { - throw LangException::invalidProviderResponse('Google Translate'); + throw LangException::payloadEncodingFailed('Google Translate'); } return $payloadJson; From bbe630c4bc0faf6b0947f90be6d35fdc7ce77d6e Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sun, 12 Jul 2026 01:06:43 +0400 Subject: [PATCH 14/16] [#533] Clean up Lang coverage and test helpers --- src/Lang/Adapters/DeepLAdapter.php | 2 +- src/Lang/Enums/ExceptionMessages.php | 2 ++ src/Lang/Exceptions/LangException.php | 8 ++++++++ tests/Unit/Lang/Adapters/DeepLAdapterTest.php | 10 ++++++++++ .../Unit/Lang/Adapters/GoogleTranslateAdapterTest.php | 10 ++++++++++ tests/Unit/Lang/Exceptions/LangExceptionTest.php | 9 +++++++++ tests/Unit/Storage/HttpClientTestCase.php | 2 +- 7 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/Lang/Adapters/DeepLAdapter.php b/src/Lang/Adapters/DeepLAdapter.php index 39b9e210e..d9e47547c 100644 --- a/src/Lang/Adapters/DeepLAdapter.php +++ b/src/Lang/Adapters/DeepLAdapter.php @@ -104,7 +104,7 @@ private function buildPayload(string $text): string $payloadJson = json_encode($payload); if ($payloadJson === false) { - throw LangException::invalidProviderResponse('DeepL'); + throw LangException::payloadEncodingFailed('DeepL'); } return $payloadJson; diff --git a/src/Lang/Enums/ExceptionMessages.php b/src/Lang/Enums/ExceptionMessages.php index 59de87094..5a8b174e5 100644 --- a/src/Lang/Enums/ExceptionMessages.php +++ b/src/Lang/Enums/ExceptionMessages.php @@ -24,6 +24,8 @@ final class ExceptionMessages extends BaseExceptionMessages public const MISCONFIGURED_DEFAULT_ADAPTER = 'Misconfigured lang default adapter config.'; + public const PAYLOAD_ENCODING_FAILED = 'The translation payload could not be encoded for `{%1}`.'; + public const INVALID_PROVIDER_RESPONSE = 'The provider `{%1}` returned an invalid translation response.'; public const PROVIDER_REQUEST_FAILED = 'The translation request to `{%1}` failed{%2}.'; diff --git a/src/Lang/Exceptions/LangException.php b/src/Lang/Exceptions/LangException.php index 31875a643..dc6b5cb3b 100644 --- a/src/Lang/Exceptions/LangException.php +++ b/src/Lang/Exceptions/LangException.php @@ -51,6 +51,14 @@ public static function invalidProviderResponse(string $provider): self ); } + public static function payloadEncodingFailed(string $provider): self + { + return new self( + _message(ExceptionMessages::PAYLOAD_ENCODING_FAILED, [$provider]), + E_WARNING + ); + } + public static function providerRequestFailed(string $provider, ?string $details = null): self { $suffix = $details ? ': ' . $details : ''; diff --git a/tests/Unit/Lang/Adapters/DeepLAdapterTest.php b/tests/Unit/Lang/Adapters/DeepLAdapterTest.php index 7a7178269..722402965 100644 --- a/tests/Unit/Lang/Adapters/DeepLAdapterTest.php +++ b/tests/Unit/Lang/Adapters/DeepLAdapterTest.php @@ -86,6 +86,16 @@ public function testDeepLAdapterThrowsIfProviderResponseIsInvalid(): void $adapter->get('Hello'); } + public function testDeepLAdapterThrowsIfPayloadCannotBeEncoded(): void + { + $adapter = new DeepLAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('The translation payload could not be encoded for `DeepL`.'); + + $adapter->get("\xB1"); + } + public function testDeepLAdapterReturnsEmptyStringWithoutProviderCall(): void { $httpClientMock = Mockery::mock(HttpClient::class); diff --git a/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php b/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php index ff0d2fff4..8b9cac02d 100644 --- a/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php +++ b/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php @@ -100,6 +100,16 @@ public function testGoogleTranslateAdapterThrowsIfProviderResponseIsInvalid(): v $adapter->get('Hello'); } + public function testGoogleTranslateAdapterThrowsIfPayloadCannotBeEncoded(): void + { + $adapter = new GoogleTranslateAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('The translation payload could not be encoded for `Google Translate`.'); + + $adapter->get("\xB1"); + } + public function testGoogleTranslateAdapterReturnsEmptyStringWithoutProviderCall(): void { $httpClientMock = Mockery::mock(HttpClient::class); diff --git a/tests/Unit/Lang/Exceptions/LangExceptionTest.php b/tests/Unit/Lang/Exceptions/LangExceptionTest.php index 8bb5dfb85..7b8892bcd 100644 --- a/tests/Unit/Lang/Exceptions/LangExceptionTest.php +++ b/tests/Unit/Lang/Exceptions/LangExceptionTest.php @@ -43,6 +43,15 @@ public function testInvalidProviderResponse(): void $this->assertSame(E_WARNING, $exception->getCode()); } + public function testPayloadEncodingFailed(): void + { + $exception = LangException::payloadEncodingFailed('Google Translate'); + + $this->assertInstanceOf(LangException::class, $exception); + $this->assertSame('The translation payload could not be encoded for `Google Translate`.', $exception->getMessage()); + $this->assertSame(E_WARNING, $exception->getCode()); + } + public function testProviderRequestFailedWithoutDetails(): void { $exception = LangException::providerRequestFailed('Google Translate'); diff --git a/tests/Unit/Storage/HttpClientTestCase.php b/tests/Unit/Storage/HttpClientTestCase.php index 184fc8810..51202ecdc 100644 --- a/tests/Unit/Storage/HttpClientTestCase.php +++ b/tests/Unit/Storage/HttpClientTestCase.php @@ -15,7 +15,7 @@ trait HttpClientTestCase protected $currentErrors; - protected $data; + protected array $data = []; protected $options; From 96fcdd5fd211bce0390a0db01a2609e405aa6c8c Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:14:59 +0400 Subject: [PATCH 15/16] [#533] Restore Lang bootstrap before controller dispatch --- src/App/Adapters/WebAppAdapter.php | 2 ++ src/App/Traits/WebAppTrait.php | 11 +++++++++++ tests/Unit/App/Adapters/WebAppAdapterTest.php | 2 ++ 3 files changed, 15 insertions(+) diff --git a/src/App/Adapters/WebAppAdapter.php b/src/App/Adapters/WebAppAdapter.php index 52ef15e48..1f629231a 100644 --- a/src/App/Adapters/WebAppAdapter.php +++ b/src/App/Adapters/WebAppAdapter.php @@ -80,6 +80,8 @@ public function start(): ?int return ExitCode::SUCCESS; } + $this->loadLanguage(); + $this->logDebugInfo(); $viewCache = $this->setupViewCache(); diff --git a/src/App/Traits/WebAppTrait.php b/src/App/Traits/WebAppTrait.php index b746e5fa0..8c88a2dcc 100644 --- a/src/App/Traits/WebAppTrait.php +++ b/src/App/Traits/WebAppTrait.php @@ -13,7 +13,9 @@ use Quantum\Config\Exceptions\ConfigException; use Quantum\Loader\Exceptions\LoaderException; use Quantum\Router\Exceptions\RouteException; +use Quantum\Lang\Exceptions\LangException; use Quantum\App\Exceptions\BaseException; +use Quantum\Lang\Factories\LangFactory; use Quantum\Di\Exceptions\DiException; use Quantum\ResourceCache\ViewCache; use Quantum\Router\RouteCollection; @@ -50,6 +52,15 @@ private function resolveRoute(): ?MatchedRoute return $matchedRoute; } + /** + * Resolve lang config and current locale before controller hooks run. + * @throws LangException|ConfigException|LoaderException|DiException|ReflectionException + */ + private function loadLanguage(): void + { + LangFactory::get(); + } + /** * @throws DiException|ReflectionException */ diff --git a/tests/Unit/App/Adapters/WebAppAdapterTest.php b/tests/Unit/App/Adapters/WebAppAdapterTest.php index 46686b0dd..3e512ae3c 100644 --- a/tests/Unit/App/Adapters/WebAppAdapterTest.php +++ b/tests/Unit/App/Adapters/WebAppAdapterTest.php @@ -26,12 +26,14 @@ public function tearDown(): void public function testWebAppAdapterStartSuccessfully(): void { request()->create('GET', '/test/am/tests'); + $this->assertFalse(config()->has('lang')); ob_start(); $result = $this->webAppAdapter->start(); ob_end_clean(); $this->assertEquals(0, $result); + $this->assertTrue(config()->has('lang')); $this->assertNull(request()->getMatchedRoute()); $this->assertNull(request()->getUri()); } From 270a13a02f018016e1180b9220574ac11d5ce8f4 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:31:33 +0400 Subject: [PATCH 16/16] [#533] Add source catalog support for remote Lang adapters --- src/Lang/Adapters/DeepLAdapter.php | 2 +- src/Lang/Adapters/GoogleTranslateAdapter.php | 2 +- src/Lang/Traits/RemoteAdapterTrait.php | 58 +++++++++++++++- tests/Unit/Lang/Adapters/DeepLAdapterTest.php | 63 ++++++++++++++++++ .../Adapters/GoogleTranslateAdapterTest.php | 66 +++++++++++++++++++ tests/_root/shared/config/lang.php | 2 + 6 files changed, 189 insertions(+), 4 deletions(-) diff --git a/src/Lang/Adapters/DeepLAdapter.php b/src/Lang/Adapters/DeepLAdapter.php index d9e47547c..ff925f632 100644 --- a/src/Lang/Adapters/DeepLAdapter.php +++ b/src/Lang/Adapters/DeepLAdapter.php @@ -52,7 +52,7 @@ public function get(string $key, $params = null): string { $text = $this->buildSourceText($key, $params); - if ($text === '') { + if ($this->shouldBypassProvider($key, $text)) { return $text; } diff --git a/src/Lang/Adapters/GoogleTranslateAdapter.php b/src/Lang/Adapters/GoogleTranslateAdapter.php index 411455199..42d6ff8d4 100644 --- a/src/Lang/Adapters/GoogleTranslateAdapter.php +++ b/src/Lang/Adapters/GoogleTranslateAdapter.php @@ -50,7 +50,7 @@ public function get(string $key, $params = null): string { $text = $this->buildSourceText($key, $params); - if ($text === '') { + if ($this->shouldBypassProvider($key, $text)) { return $text; } diff --git a/src/Lang/Traits/RemoteAdapterTrait.php b/src/Lang/Traits/RemoteAdapterTrait.php index baa9b6cc1..8942b09b2 100644 --- a/src/Lang/Traits/RemoteAdapterTrait.php +++ b/src/Lang/Traits/RemoteAdapterTrait.php @@ -10,14 +10,18 @@ namespace Quantum\Lang\Traits; -use ErrorException; -use Quantum\Config\Exceptions\ConfigException; use Quantum\HttpClient\Exceptions\HttpClientException; +use Quantum\Lang\Adapters\GoogleTranslateAdapter; +use Quantum\Config\Exceptions\ConfigException; +use Quantum\Loader\Exceptions\LoaderException; use Quantum\Lang\Exceptions\LangException; use Quantum\App\Exceptions\BaseException; +use Quantum\Lang\Adapters\DeepLAdapter; +use Quantum\Lang\Adapters\FileAdapter; use Quantum\Di\Exceptions\DiException; use Quantum\HttpClient\HttpClient; use ReflectionException; +use ErrorException; trait RemoteAdapterTrait { @@ -30,12 +34,39 @@ trait RemoteAdapterTrait /** * @param array|string|null $params + * @throws LangException|LoaderException|ConfigException|DiException|BaseException|ReflectionException */ protected function buildSourceText(string $key, array|string $params = null): string { + if ($this->usesSourceCatalog()) { + $sourceLocale = $this->getSourceLocale(); + + if ($sourceLocale === null || $sourceLocale === '') { + throw LangException::missingConfig($this->getSourceLocaleConfigPath()); + } + + return (new FileAdapter($sourceLocale))->get($key, $params); + } + return $params ? _message($key, $params) : $key; } + protected function shouldBypassProvider(string $key, string $text): bool + { + if ($text === '') { + return true; + } + + if (!$this->usesSourceCatalog()) { + return false; + } + + $sourceLocale = $this->getSourceLocale(); + + return $text === $key + || ($sourceLocale !== null && strcasecmp($sourceLocale, $this->lang) === 0); + } + /** * @throws ConfigException|DiException|BaseException|ReflectionException */ @@ -136,4 +167,27 @@ private function getCacheKey(string $adapter, string $text): string return $prefix . sha1($adapter . '|' . $this->lang . '|' . $sourceLocale . '|' . $text); } + + private function usesSourceCatalog(): bool + { + return filter_var($this->params['use_source_catalog'] ?? false, FILTER_VALIDATE_BOOLEAN); + } + + private function getSourceLocale(): ?string + { + if (!isset($this->params['source_locale']) || $this->params['source_locale'] === '') { + return null; + } + + return (string) $this->params['source_locale']; + } + + private function getSourceLocaleConfigPath(): string + { + return match (static::class) { + DeepLAdapter::class => 'lang.deepl.source_locale', + GoogleTranslateAdapter::class => 'lang.google_translate.source_locale', + default => 'lang.source_locale', + }; + } } diff --git a/tests/Unit/Lang/Adapters/DeepLAdapterTest.php b/tests/Unit/Lang/Adapters/DeepLAdapterTest.php index 722402965..e3ef1af14 100644 --- a/tests/Unit/Lang/Adapters/DeepLAdapterTest.php +++ b/tests/Unit/Lang/Adapters/DeepLAdapterTest.php @@ -106,6 +106,69 @@ public function testDeepLAdapterReturnsEmptyStringWithoutProviderCall(): void $this->assertSame('', $adapter->get('')); } + public function testDeepLAdapterTranslatesResolvedSourceCatalogText(): void + { + $this->currentResponse = (object) [ + 'translations' => [ + (object) ['text' => 'Hola'], + ], + ]; + + $adapter = new DeepLAdapter('es', $this->getParams([ + 'use_source_catalog' => true, + 'source_locale' => 'en', + ]), $this->mockHttpClient()); + + $this->assertSame('Hola', $adapter->get('custom.test')); + $this->assertJsonStringEqualsJsonString( + json_encode([ + 'text' => ['Testing'], + 'target_lang' => 'ES', + 'source_lang' => 'EN', + ]), + (string) $this->data[$this->url] + ); + } + + public function testDeepLAdapterReturnsResolvedSourceTextWithoutProviderCallWhenTargetMatchesSourceLocale(): void + { + $httpClientMock = Mockery::mock(HttpClient::class); + $httpClientMock->shouldNotReceive('createRequest'); + + $adapter = new DeepLAdapter('en', $this->getParams([ + 'use_source_catalog' => true, + 'source_locale' => 'en', + ]), $httpClientMock); + + $this->assertSame('Testing', $adapter->get('custom.test')); + } + + public function testDeepLAdapterReturnsKeyWithoutProviderCallWhenSourceCatalogMisses(): void + { + $httpClientMock = Mockery::mock(HttpClient::class); + $httpClientMock->shouldNotReceive('createRequest'); + + $adapter = new DeepLAdapter('es', $this->getParams([ + 'use_source_catalog' => true, + 'source_locale' => 'en', + ]), $httpClientMock); + + $this->assertSame('custom.missing', $adapter->get('custom.missing')); + } + + public function testDeepLAdapterThrowsIfSourceCatalogLocaleIsMissing(): void + { + $adapter = new DeepLAdapter('es', $this->getParams([ + 'use_source_catalog' => true, + 'source_locale' => null, + ]), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('Could not load config `lang.deepl.source_locale` properly.'); + + $adapter->get('custom.test'); + } + public function testDeepLAdapterThrowsIfProviderRequestFails(): void { $this->currentErrors = ['timeout']; diff --git a/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php b/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php index 8b9cac02d..1fac0fc0f 100644 --- a/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php +++ b/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php @@ -120,6 +120,72 @@ public function testGoogleTranslateAdapterReturnsEmptyStringWithoutProviderCall( $this->assertSame('', $adapter->get('')); } + public function testGoogleTranslateAdapterTranslatesResolvedSourceCatalogText(): void + { + $this->currentResponse = (object) [ + 'data' => (object) [ + 'translations' => [ + (object) ['translatedText' => 'Hola'], + ], + ], + ]; + + $adapter = new GoogleTranslateAdapter('es', $this->getParams([ + 'use_source_catalog' => true, + 'source_locale' => 'en', + ]), $this->mockHttpClient()); + + $this->assertSame('Hola', $adapter->get('custom.test')); + $this->assertJsonStringEqualsJsonString( + json_encode([ + 'q' => 'Testing', + 'target' => 'es', + 'format' => 'text', + 'source' => 'en', + ]), + (string) $this->data[$this->url] + ); + } + + public function testGoogleTranslateAdapterReturnsResolvedSourceTextWithoutProviderCallWhenTargetMatchesSourceLocale(): void + { + $httpClientMock = Mockery::mock(HttpClient::class); + $httpClientMock->shouldNotReceive('createRequest'); + + $adapter = new GoogleTranslateAdapter('en', $this->getParams([ + 'use_source_catalog' => true, + 'source_locale' => 'en', + ]), $httpClientMock); + + $this->assertSame('Testing', $adapter->get('custom.test')); + } + + public function testGoogleTranslateAdapterReturnsKeyWithoutProviderCallWhenSourceCatalogMisses(): void + { + $httpClientMock = Mockery::mock(HttpClient::class); + $httpClientMock->shouldNotReceive('createRequest'); + + $adapter = new GoogleTranslateAdapter('es', $this->getParams([ + 'use_source_catalog' => true, + 'source_locale' => 'en', + ]), $httpClientMock); + + $this->assertSame('custom.missing', $adapter->get('custom.missing')); + } + + public function testGoogleTranslateAdapterThrowsIfSourceCatalogLocaleIsMissing(): void + { + $adapter = new GoogleTranslateAdapter('es', $this->getParams([ + 'use_source_catalog' => true, + 'source_locale' => null, + ]), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('Could not load config `lang.google_translate.source_locale` properly.'); + + $adapter->get('custom.test'); + } + public function testGoogleTranslateAdapterThrowsIfProviderRequestFails(): void { $this->currentErrors = ['timeout']; diff --git a/tests/_root/shared/config/lang.php b/tests/_root/shared/config/lang.php index c55ff4f9f..2daff3443 100644 --- a/tests/_root/shared/config/lang.php +++ b/tests/_root/shared/config/lang.php @@ -11,6 +11,7 @@ 'default_locale' => 'en', 'file' => [], 'deepl' => [ + 'use_source_catalog' => false, 'auth_key' => '', 'source_locale' => null, 'cache' => [ @@ -21,6 +22,7 @@ ], ], 'google_translate' => [ + 'use_source_catalog' => false, 'api_key' => '', 'source_locale' => null, 'cache' => [