diff --git a/Sources/Lang.php b/Sources/Lang.php index 0b353a3b01..a27835243e 100644 --- a/Sources/Lang.php +++ b/Sources/Lang.php @@ -416,23 +416,28 @@ public static function addDirs(array|string $custom_dirs = []): void } else { self::$dirs[] = Sapi::canonicalPath(Config::$languagesdir); - // Make sure we have Theme::$current->settings - if not we're in - // trouble and need to find it! - if (empty(Theme::$current->settings['default_theme_dir'])) { - Theme::loadEssential(false); - } + // If there's no database or we're installing, we can't load the theme. + // Otherwise, make sure to include the theme's language directories. + if (isset(Db\DatabaseApi::$db) && !\defined('SMF_INSTALLING')) { + // Is the theme already loaded? + $theme_loaded = !empty(Utils::$context['theme_loaded']); - foreach (['theme_dir', 'base_theme_dir', 'default_theme_dir'] as $var) { - if (isset(Theme::$current->settings[$var])) { - self::$dirs[] = Sapi::canonicalPath(Theme::$current->settings[$var] . '/languages'); + if (empty(Theme::$current->settings['default_theme_dir'])) { + Theme::loadEssential(false); + } + + foreach (['theme_dir', 'base_theme_dir', 'default_theme_dir'] as $var) { + if (isset(Theme::$current->settings[$var])) { + self::$dirs[] = Sapi::canonicalPath(Theme::$current->settings[$var] . '/languages'); + } } - } - // Don't count this as loading the theme. - Utils::$context['theme_loaded'] = false; + // Don't count this as loading the theme. + Utils::$context['theme_loaded'] = $theme_loaded; + } } - self::$dirs = array_unique(self::$dirs); + self::$dirs = array_filter(array_unique(self::$dirs), 'is_dir'); } /** @@ -454,43 +459,26 @@ public static function get(bool $use_cache = true): array ) ) ) { - // Special case during install. - if (\defined('SMF_INSTALLING')) { - $language_directories = [Config::$languagesdir]; + // Shall we include the theme's language directories? + if ( + // Skip this if we're installing. + !\defined('SMF_INSTALLING') + // Can't load the theme without the database. + && isset(Db\DatabaseApi::$db) + // Only do this if the theme hasn't been loaded yet. + && empty(Theme::$current->settings['default_theme_dir']) + ) { + // We use Theme::load() here instead of Theme::loadEssential() + // in order to take into account any board-specific theme, + // calls to integration hooks, etc. Plus, Theme::load() will + // call Lang::addDirs() for us. + Theme::load(0, false); } else { - // If we don't have our theme information yet, let's get it. - if (empty(Theme::$current->settings['default_theme_dir'])) { - Theme::load(0, false); - } - - // Default language directories to try. - $language_directories = [ - Config::$languagesdir, - Theme::$current->settings['default_theme_dir'] . '/languages', - ]; - - if ( - !empty(Theme::$current->settings['actual_theme_dir']) - && Theme::$current->settings['actual_theme_dir'] != Theme::$current->settings['default_theme_dir'] - ) { - $language_directories[] = Theme::$current->settings['actual_theme_dir'] . '/languages'; - } - - // We possibly have a base theme directory. - if (!empty(Theme::$current->settings['base_theme_dir'])) { - $language_directories[] = Theme::$current->settings['base_theme_dir'] . '/languages'; - } + // Make sure Lang::$dirs is populated. + self::addDirs(); } - // Remove any duplicates. - $language_directories = array_unique($language_directories); - - foreach ($language_directories as $language_dir) { - // Can't look in here... doesn't exist! - if (!file_exists($language_dir)) { - continue; - } - + foreach (self::$dirs as $language_dir) { $dir = dir($language_dir); while ($entry = $dir->read()) { diff --git a/tests/Unit/LangTest.php b/tests/Unit/LangTest.php new file mode 100644 index 0000000000..6b7f030eef --- /dev/null +++ b/tests/Unit/LangTest.php @@ -0,0 +1,169 @@ + addDirs() -> Theme::loadEssential(), and the Theme + * constructor's first act is a query, so a process with no connection died on + * "Typed static property SMF\Db\DatabaseApi::$db must not be accessed before + * initialization" thrown out of Theme.php - a message with nothing about + * languages in it, from three calls below where the test was looking. + * + * The rest of the class - censorText(), sentenceList(), numberFormat(), + * formatText(), tokenTxtReplace(), getLocaleFromLanguageName() - never needed + * the database and could always have been tested. What is new here is + * everything that has to find a file first. + */ +#[CoversClass(Lang::class)] +class LangTest extends TestCase +{ + /********************* + * Internal properties + *********************/ + + /** + * @var array Lang's statics as they were before the test ran. + */ + private array $backup = []; + + /**************** + * Public methods + ****************/ + + public function testItLoadsLanguageStringsWithNoDatabase(): void + { + $this->assertSame('en_US', Lang::load('General')); + + $this->assertArrayHasKey('number_of_days', Lang::$txt); + $this->assertArrayHasKey('days', Lang::$txt); + } + + public function testItDefaultsToTheForumLanguageWhenThereIsNoUser(): void + { + // User::$me is a typed static with no default, and reading an + // uninitialized one throws rather than yielding null. load() gets away + // with `User::$me->language ?? Config::$language` only because ?? + // evaluates its left side in an isset() context, which is easy to + // break by "tidying" it into something that reads the property first. + $this->assertFalse(isset(User::$me)); + + $this->assertSame(Config::$language, Lang::load('General')); + } + + public function testItSearchesOnlyTheLanguagesDirectoryWhenThereIsNoTheme(): void + { + Lang::addDirs(); + + // Lang::$dirs is private, and there is no accessor. It is worth reading + // anyway: the whole behaviour under test is which directories end up in + // it, and every other assertion here can only see that a file was found + // somewhere. + $dirs = (new \ReflectionProperty(Lang::class, 'dirs'))->getValue(); + + $this->assertSame( + [Sapi::canonicalPath(Config::$languagesdir)], + array_values($dirs), + ); + } + + public function testItIgnoresACustomDirectoryThatIsNotThere(): void + { + Lang::addDirs(Config::$boarddir . '/no/such/directory'); + + $dirs = (new \ReflectionProperty(Lang::class, 'dirs'))->getValue(); + + $this->assertSame( + [Sapi::canonicalPath(Config::$languagesdir)], + array_values($dirs), + ); + } + + public function testItListsTheInstalledLanguages(): void + { + $languages = Lang::get(false); + + $this->assertArrayHasKey('en_US', $languages); + + // The name comes from $txt['native_name'] inside the file, which get() + // reads a line at a time rather than by including it. + $this->assertSame('English (US)', $languages['en_US']['name']); + + $this->assertSame( + Sapi::canonicalPath(Config::$languagesdir . '/en_US/General.php'), + $languages['en_US']['location'], + ); + } + + public function testItLoadsTheFileAStringWasAskedForFrom(): void + { + // Nothing has been loaded at this point; naming the file is what makes + // getTxt() go and find it. + $this->assertSame('1 day', Lang::getTxt('number_of_days', [1], file: 'General')); + $this->assertSame('2 days', Lang::getTxt('number_of_days', [2], file: 'General')); + } + + public function testItFindsAStringThatOnlyExistsInAFileOnDisk(): void + { + $this->assertFalse(Lang::txtExists('actual_theme_dir')); + + $this->assertTrue(Lang::txtExists('actual_theme_dir', file: 'Themes')); + $this->assertFalse(Lang::txtExists('no_such_string_anywhere', file: 'Themes')); + } + + /****************** + * Internal methods + ******************/ + + protected function setUp(): void + { + parent::setUp(); + + $this->backup = self::langState(); + } + + protected function tearDown(): void + { + // PHPUnit does not reset SMF's statics between tests, and a test here + // leaves a loaded language behind: 700-odd strings in Lang::$txt, the + // directories in Lang::$dirs, and the record in Lang::$already_loaded + // that stops a second load() from doing anything. Each test starts from + // nothing loaded, which is the state the ones above are about. + foreach ($this->backup as $name => $value) { + (new \ReflectionProperty(Lang::class, $name))->setValue(null, $value); + } + + parent::tearDown(); + } + + /************************* + * Internal static methods + *************************/ + + /** + * The statics that loading a language file writes to. + * + * @return array Property name => current value. + */ + private static function langState(): array + { + $state = []; + + foreach (['txt', 'txtBirthdayEmails', 'tztxt', 'editortxt', 'helptxt', 'dirs', 'already_loaded', 'loaded_keys'] as $name) { + $state[$name] = (new \ReflectionProperty(Lang::class, $name))->getValue(); + } + + return $state; + } +} diff --git a/tests/Unit/TimeIntervalTest.php b/tests/Unit/TimeIntervalTest.php index f6ce115b9e..13981cb832 100644 --- a/tests/Unit/TimeIntervalTest.php +++ b/tests/Unit/TimeIntervalTest.php @@ -6,11 +6,21 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use SMF\Lang; use SMF\TimeInterval; #[CoversClass(TimeInterval::class)] class TimeIntervalTest extends TestCase { + /********************* + * Internal properties + *********************/ + + /** + * @var array Lang's statics as they were before the test ran. + */ + private array $lang_backup = []; + /**************** * Public methods ****************/ @@ -139,13 +149,100 @@ public function testFormatFallsBackToDaysWhenTheTotalIsUnknown(): void } /* - * localize() is not covered here. It is the other half of what #9499 put - * right - the unit order stopped depending on how the caller wrote the - * argument, and asking for 'a' when the total number of days is unknown now - * falls back to years, months and days instead of producing nothing - but - * every branch of it goes through Lang::getTxt(), which loads a language - * file, which wants Theme::$current and therefore Db::$db. It belongs to an - * integration suite. toParsable() above covers the same walk over the units - * with the strings hard coded, so the ordering is not entirely unwatched. + * Everything below covers localize(), which this file used to say belonged + * to an integration suite: every branch of it goes through Lang::getTxt(), + * which loads a language file, which wanted Theme::$current and therefore + * Db::$db. #9581 removed that, so the other half of what #9499 put right is + * now watched here rather than only implied by toParsable() above. */ + + public function testItLocalisesEachUnitWithItsOwnPlural(): void + { + // Same shape as toParsable(), but through the language file: the units + // are pluralised one at a time, and the result is a sentence rather + // than a list of fields. + $this->assertSame( + '1 year, 2 months, and 3 days', + (new TimeInterval('P1Y2M3D'))->localize(), + ); + } + + public function testTheUnitOrderDoesNotDependOnHowTheCallerWroteIt(): void + { + // localize() walks its own table of units rather than the array it was + // handed, so the same three units asked for backwards come back in the + // order a reader expects. + $this->assertSame( + '1 year, 2 months, and 3 days', + (new TimeInterval('P1Y2M3D'))->localize(['d', 'y', 'm']), + ); + } + + public function testAskingForTheTotalDaysFallsBackWhenThereIsNoTotal(): void + { + // 'a' is the total number of days, which only an interval produced by + // diff() has. On any other one it is substituted with years, months and + // days; without that, nothing would match and the answer would be a + // flat '0 days'. + $this->assertSame('1 year', (new TimeInterval('P1Y'))->localize(['a'])); + $this->assertSame('1 day', (new TimeInterval('P1DT2H'))->localize(['a'])); + } + + public function testItSaysZeroOfTheSmallestUnitItWasAskedFor(): void + { + // Empty units are dropped so the output is not padded with "0 hours, + // 0 minutes", but dropping all of them would leave nothing to say. + $this->assertSame('0 seconds', (new TimeInterval('PT0S'))->localize(['h', 'i', 's'])); + $this->assertSame('0 days', (new TimeInterval('PT0S'))->localize(['d'])); + } + + public function testFractionalSecondsAreFoldedIntoTheSeconds(): void + { + // 's' and 'f' are one number to a reader, not two. + $this->assertSame('1.5 seconds', (new TimeInterval('PT1.5S'))->localize(['s', 'f'])); + } + + /****************** + * Internal methods + ******************/ + + protected function setUp(): void + { + parent::setUp(); + + $this->lang_backup = self::langState(); + } + + protected function tearDown(): void + { + // localize() loads the General language file, and PHPUnit does not + // reset SMF's statics between tests. Leaving 700-odd strings and a + // record in Lang::$already_loaded behind would change what every test + // after this one starts from. + foreach ($this->lang_backup as $name => $value) { + (new \ReflectionProperty(Lang::class, $name))->setValue(null, $value); + } + + parent::tearDown(); + } + + /************************* + * Internal static methods + *************************/ + + /** + * The statics that loading a language file writes to. + * + * @return array Property name => current value. + */ + private static function langState(): array + { + $state = []; + + foreach (['txt', 'dirs', 'already_loaded', 'loaded_keys'] as $name) { + $state[$name] = (new \ReflectionProperty(Lang::class, $name))->getValue(); + } + + return $state; + } }