#import @import WMF; @interface TWNStringsTests : XCTestCase @property (class, strong, nonatomic, readonly) NSArray *bundledLprojFiles; @property (class, strong, nonatomic, readonly) NSArray *iOSLprojFiles; @property (class, strong, nonatomic, readonly) NSArray *twnLprojFiles; @property (class, strong, nonatomic, readonly) NSString *bundleRoot; @property (class, strong, nonatomic, readonly) NSArray *twnInfoPlistFilePaths; @property (class, strong, nonatomic, readonly) NSArray *iOSInfoPlistFilePaths; @property (class, strong, nonatomic, readonly) NSString *twnLocalizationsDirectory; @property (class, strong, nonatomic, readonly) NSString *iOSLocalizationsDirectory; @end @implementation TWNStringsTests - (void)setUp { [super setUp]; } + (NSString *)iOSLocalizationsDirectory { return [SOURCE_ROOT_DIR stringByAppendingPathComponent:@"Wikipedia/iOS Native Localizations"]; } + (NSString *)twnLocalizationsDirectory { return [SOURCE_ROOT_DIR stringByAppendingPathComponent:@"Wikipedia/Localizations"]; } + (NSString *)bundleRoot { return [[NSBundle wmf_localizationBundle] bundlePath]; } + (NSString *)appBundleRoot { return [[NSBundle mainBundle] bundlePath]; } + (NSArray *)bundledLprojFiles { static dispatch_once_t onceToken; static NSArray *bundledLprojFiles; dispatch_once(&onceToken, ^{ bundledLprojFiles = [[[[NSFileManager defaultManager] contentsOfDirectoryAtPath:TWNStringsTests.bundleRoot error:nil] filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"pathExtension='lproj'"]] valueForKey:@"lowercaseString"]; }); return bundledLprojFiles; } + (NSArray *)twnInfoPlistFilePaths { static dispatch_once_t onceToken; static NSArray *twnInfoPlistFilePaths; dispatch_once(&onceToken, ^{ twnInfoPlistFilePaths = [self.twnLprojFiles wmf_map:^NSString *(NSString *lprojFileName) { return [[self.twnLocalizationsDirectory stringByAppendingPathComponent:lprojFileName] stringByAppendingPathComponent:@"InfoPlist.strings"]; }]; }); return twnInfoPlistFilePaths; } + (NSArray *)iOSInfoPlistFilePaths { static dispatch_once_t onceToken; static NSArray *infoPlistFilePaths; dispatch_once(&onceToken, ^{ infoPlistFilePaths = [self.iOSLprojFiles wmf_map:^NSString *(NSString *lprojFileName) { return [[self.iOSLocalizationsDirectory stringByAppendingPathComponent:lprojFileName] stringByAppendingPathComponent:@"InfoPlist.strings"]; }]; }); return infoPlistFilePaths; } + (NSArray *)twnLprojFiles { static dispatch_once_t onceToken; static NSArray *twnLprojFiles; dispatch_once(&onceToken, ^{ twnLprojFiles = [[[[NSFileManager defaultManager] contentsOfDirectoryAtPath:self.twnLocalizationsDirectory error:nil] filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"pathExtension='lproj'"]] valueForKey:@"lowercaseString"]; }); return twnLprojFiles; } + (NSArray *)iOSLprojFiles { static dispatch_once_t onceToken; static NSArray *iOSLprojFiles; dispatch_once(&onceToken, ^{ iOSLprojFiles = [[[[NSFileManager defaultManager] contentsOfDirectoryAtPath:self.iOSLocalizationsDirectory error:nil] filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"pathExtension='lproj'"]] valueForKey:@"lowercaseString"]; }); return iOSLprojFiles; } - (NSDictionary *)getPluralizableStringsDictFromLprogAtPath:(NSString *)lprojPath { NSString *stringsFilePath = [lprojPath stringByAppendingPathComponent:@"Localizable.stringsdict"]; return [self getDictFromPListAtPath:stringsFilePath]; } - (NSDictionary *)getTranslationStringsDictFromLprogAtPath:(NSString *)lprojPath { NSString *stringsFilePath = [lprojPath stringByAppendingPathComponent:@"Localizable.strings"]; return [self getDictFromPListAtPath:stringsFilePath]; } - (NSDictionary *)getDictFromPListAtPath:(NSString *)path { BOOL isDirectory = NO; if ([[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isDirectory]) { return [NSDictionary dictionaryWithContentsOfFile:path]; } return nil; } - (void)testLprojCount { XCTAssert(TWNStringsTests.iOSLprojFiles.count > 0); XCTAssert(TWNStringsTests.twnLprojFiles.count > 0); } + (NSRegularExpression *)reverseiOSTokenRegex { static dispatch_once_t onceToken; static NSRegularExpression *reverseiOSTokenRegex; dispatch_once(&onceToken, ^{ reverseiOSTokenRegex = [NSRegularExpression regularExpressionWithPattern:@"(:?[^%%])(:?[0-9]+)(?:[$])(:?[^@dDuUxXoOfeEgGcCsSpaAF])" options:0 error:nil]; }); return reverseiOSTokenRegex; } + (NSRegularExpression *)reverseTWNTokenRegex { static dispatch_once_t onceToken; static NSRegularExpression *reverseTWNTokenRegex; dispatch_once(&onceToken, ^{ reverseTWNTokenRegex = [NSRegularExpression regularExpressionWithPattern:@"(:?[0-9])(?:[$])(:?[^0-9])" options:0 error:nil]; }); return reverseTWNTokenRegex; } + (NSRegularExpression *)twnTokenRegex { static dispatch_once_t onceToken; static NSRegularExpression *twnTokenRegex; dispatch_once(&onceToken, ^{ twnTokenRegex = [NSRegularExpression regularExpressionWithPattern:@"(?:[$])(:?[0-9]+)" options:0 error:nil]; }); return twnTokenRegex; } + (NSRegularExpression *)percentNumberRegex { static dispatch_once_t onceToken; static NSRegularExpression *percentNumberRegex; dispatch_once(&onceToken, ^{ percentNumberRegex = [NSRegularExpression regularExpressionWithPattern:@"(?:[%%])(:?[0-9s])" options:0 error:nil]; }); return percentNumberRegex; } + (NSRegularExpression *)iOSTokenRegex { static dispatch_once_t onceToken; static NSRegularExpression *iOSTokenRegex; dispatch_once(&onceToken, ^{ iOSTokenRegex = [NSRegularExpression regularExpressionWithPattern:@"%([0-9]*)\\$?([@dDuUxXoOfeEgGcCsSpaAF])" options:0 error:nil]; }); return iOSTokenRegex; } - (void)assertLprojFiles:(NSArray *)lprojFiles withTranslationStringsInDirectory:(NSString *)directory haveNoMatchesWithRegex:(NSRegularExpression *)regex { XCTAssertNotNil(regex); for (NSString *lprojFileName in lprojFiles) { if (![TWNStringsTests localeForLprojFilenameIsAvailableOniOS:lprojFileName]) { continue; } NSDictionary *stringsDict = [self getTranslationStringsDictFromLprogAtPath:[directory stringByAppendingPathComponent:lprojFileName]]; for (NSString *key in stringsDict) { NSString *localizedString = stringsDict[key]; NSTextCheckingResult *result = [regex firstMatchInString:localizedString options:0 range:NSMakeRange(0, localizedString.length)]; XCTAssertNil(result, @"Invalid character in string: %@ for key: %@ in locale: %@", localizedString, key, lprojFileName); } } } - (void)assertLprojFiles:(NSArray *)lprojFiles withTranslationStringsInDirectory:(NSString *)directory doesNotContain:(NSString *)banned { XCTAssertNotNil(banned); NSString * bannedUpper = [banned uppercaseString]; for (NSString *lprojFileName in lprojFiles) { if (![TWNStringsTests localeForLprojFilenameIsAvailableOniOS:lprojFileName]) { continue; } NSDictionary *stringsDict = [self getTranslationStringsDictFromLprogAtPath:[directory stringByAppendingPathComponent:lprojFileName]]; for (NSString *key in stringsDict) { NSString *localizedString = stringsDict[key]; BOOL doesContainBannedString = [[localizedString uppercaseString] containsString:bannedUpper]; XCTAssertFalse(doesContainBannedString, @"Invalid substring %@ found in: %@ for key: %@ in locale: %@", banned, localizedString, key, lprojFileName); } } } - (void)testiOSTranslationStringForTWNSubstitutionShortcuts { [self assertLprojFiles:TWNStringsTests.iOSLprojFiles withTranslationStringsInDirectory:TWNStringsTests.bundleRoot haveNoMatchesWithRegex:TWNStringsTests.twnTokenRegex]; } - (void)testIncomingTranslationStringForReversedSubstitutionShortcuts { [self assertLprojFiles:TWNStringsTests.twnLprojFiles withTranslationStringsInDirectory:TWNStringsTests.twnLocalizationsDirectory haveNoMatchesWithRegex:TWNStringsTests.reverseTWNTokenRegex]; } - (void)testiOSTranslationStringForReversedSubstitutionShortcuts { [self assertLprojFiles:TWNStringsTests.iOSLprojFiles withTranslationStringsInDirectory:TWNStringsTests.bundleRoot haveNoMatchesWithRegex:TWNStringsTests.reverseiOSTokenRegex]; } - (void)testIncomingTranslationStringForPercentTokens { [self assertLprojFiles:TWNStringsTests.twnLprojFiles withTranslationStringsInDirectory:TWNStringsTests.twnLocalizationsDirectory haveNoMatchesWithRegex:TWNStringsTests.percentNumberRegex]; } + (NSRegularExpression *)htmlTagRegex { static NSRegularExpression *htmlTagRegex; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ htmlTagRegex = [NSRegularExpression regularExpressionWithPattern:@"(<[^>]*>)([^<]*)" options:NSRegularExpressionCaseInsensitive error:nil]; }); return htmlTagRegex; } - (void)testIncomingTranslationStringForHTML { [self assertLprojFiles:TWNStringsTests.twnLprojFiles withTranslationStringsInDirectory:TWNStringsTests.bundleRoot haveNoMatchesWithRegex:TWNStringsTests.htmlTagRegex]; } - (void)testIncomingTranslationStringForNBSP { [self assertLprojFiles:TWNStringsTests.twnLprojFiles withTranslationStringsInDirectory:TWNStringsTests.bundleRoot doesNotContain:@" "]; } - (void)testIncomingTranslationStringForBracketSubstitutions { for (NSString *lprojFileName in TWNStringsTests.twnLprojFiles) { if (![lprojFileName isEqualToString:@"qqq.lproj"]) { NSDictionary *stringsDict = [self getTranslationStringsDictFromLprogAtPath:[TWNStringsTests.bundleRoot stringByAppendingPathComponent:lprojFileName]]; NSDictionary *pluralizableStringsDict = [self getPluralizableStringsDictFromLprogAtPath:[TWNStringsTests.bundleRoot stringByAppendingPathComponent:lprojFileName]]; for (NSString *key in stringsDict) { NSString *localizedString = stringsDict[key]; if ([localizedString containsString:@"{{"]) { NSString *lowercaseString = localizedString.lowercaseString; if ([lowercaseString containsString:@"{{plural:"]) { XCTAssertNotNil([pluralizableStringsDict objectForKey:key], @"Localizable string %@ in %@ with PLURAL: needs an entry in the corresponding stringsdict file. This likely means that this language's Localizable.stringsdict hasn't been added to the project yet.", key, lprojFileName); } else if (![lowercaseString containsString:@"{{formatnum:$"]) { XCTAssertTrue(false, @"%@ in %@ has unsupported {{ }} in localization.", key, lprojFileName); } } } } } } - (void)testiOSTranslationStringForBracketSubstitutionsAndMismatchedTokens { NSDictionary *enStrings = [self getTranslationStringsDictFromLprogAtPath:[TWNStringsTests.bundleRoot stringByAppendingPathComponent:@"en.lproj"]]; NSMutableDictionary *enTokensByKey = [NSMutableDictionary dictionaryWithCapacity:enStrings.count]; for (NSString *lprojFileName in TWNStringsTests.iOSLprojFiles) { if (![lprojFileName isEqualToString:@"qqq.lproj"]) { NSDictionary *stringsDict = [self getTranslationStringsDictFromLprogAtPath:[TWNStringsTests.bundleRoot stringByAppendingPathComponent:lprojFileName]]; NSDictionary *pluralizableStringsDict = [self getPluralizableStringsDictFromLprogAtPath:[TWNStringsTests.bundleRoot stringByAppendingPathComponent:lprojFileName]]; for (NSString *key in stringsDict) { NSString *localizedString = stringsDict[key]; if ([localizedString containsString:@"{{"]) { NSString *lowercaseString = localizedString.lowercaseString; if ([lowercaseString containsString:@"{{plural:%"]) { XCTAssertNotNil([pluralizableStringsDict objectForKey:key], @"Localizable string %@ in %@ with PLURAL: needs an entry in the corresponding stringsdict file. This likely means that this language's Localizable.stringsdict hasn't been added to the project yet.", key, lprojFileName); } else { XCTAssertTrue(false, @"Unsupported {{ }} in localization"); } } NSMutableDictionary *localizedTokens = [NSMutableDictionary new]; NSRegularExpression *tokenRegex = [TWNStringsTests iOSTokenRegex]; [tokenRegex enumerateMatchesInString:localizedString options:0 range:NSMakeRange(0, localizedString.length) usingBlock:^(NSTextCheckingResult *_Nullable result, NSMatchingFlags flags, BOOL *_Nonnull stop) { NSString *tokenKey = [tokenRegex replacementStringForResult:result inString:localizedString offset:0 template:@"$1"]; if ([tokenKey isEqualToString:@""]) { tokenKey = @"1"; XCTAssertNil(localizedTokens[tokenKey], @"There can only be one unordered token in a localization string. Switch to ordered tokens:\n%@\n%@", key, localizedString); } NSString *value = [tokenRegex replacementStringForResult:result inString:localizedString offset:0 template:@"$2"]; localizedTokens[tokenKey] = value; }]; NSString *enString = enStrings[key]; NSMutableDictionary *enTokens = enTokensByKey[key]; if (enString) { if (!enTokens) { enTokens = [NSMutableDictionary new]; [tokenRegex enumerateMatchesInString:enString options:0 range:NSMakeRange(0, enString.length) usingBlock:^(NSTextCheckingResult *_Nullable result, NSMatchingFlags flags, BOOL *_Nonnull stop) { NSString *tokenKey = [tokenRegex replacementStringForResult:result inString:enString offset:0 template:@"$1"]; if ([tokenKey isEqualToString:@""]) { tokenKey = @"1"; XCTAssertNil(enTokens[tokenKey], @"There can only be one unordered token in a localization string. Switch to ordered tokens:\n%@\n%@", key, enString); } NSString *value = [tokenRegex replacementStringForResult:result inString:enString offset:0 template:@"$2"]; enTokens[tokenKey] = value; }]; enTokensByKey[key] = enTokens; } XCTAssertEqualObjects(localizedTokens, enTokens, @"%@ translation for %@ has incorrect tokens:\n%@\n%@", lprojFileName, key, enString, localizedString); } } } } } - (NSArray *)unbundledLprojFiles { NSMutableArray *files = [TWNStringsTests.twnLprojFiles mutableCopy]; [files removeObjectsInArray:TWNStringsTests.bundledLprojFiles]; return files; } - (NSArray *)unbundledLprojFilesWithTranslations { // unbundled lProj's containing "Localizable.strings" return [self.unbundledLprojFiles wmf_select:^BOOL(NSString *lprojFileName) { BOOL isDirectory = NO; NSString *localizableStringsFilePath = [[TWNStringsTests.twnLocalizationsDirectory stringByAppendingPathComponent:lprojFileName] stringByAppendingPathComponent:@"Localizable.strings"]; return [[NSFileManager defaultManager] fileExistsAtPath:localizableStringsFilePath isDirectory:&isDirectory]; }]; } + (NSSet *)supportedLocales { static dispatch_once_t onceToken; static NSSet *supportedLocales; dispatch_once(&onceToken, ^{ NSArray *lowercaseAvailableLocales = [[NSLocale availableLocaleIdentifiers] wmf_map:^id(NSString *locale) { return [locale lowercaseString]; }]; supportedLocales = [NSSet setWithArray:lowercaseAvailableLocales]; }); return supportedLocales; } + (BOOL)localeForLprojFilenameIsAvailableOniOS:(NSString *)lprojFileName { NSString *localeIdentifier = [[lprojFileName substringToIndex:lprojFileName.length - 6] lowercaseString]; //remove .lproj suffix return [[TWNStringsTests supportedLocales] containsObject:localeIdentifier]; } - (void)testAllSupportedTranslatedLanguagesWereAddedToProjectLocalizations { // Fails if any supported languages have translations (in "Localizable.strings") but are // not yet bundled in the project. // So, if this test fails, the languages listed will need to be added these to the project's localizations. NSArray *files = [self.unbundledLprojFilesWithTranslations mutableCopy]; for (NSString *file in files) { XCTAssert(![TWNStringsTests localeForLprojFilenameIsAvailableOniOS:file], @"Missing supported translation for %@", file); } } - (void)testKeysForUnderscores { for (NSString *lprojFileName in TWNStringsTests.twnLprojFiles) { NSDictionary *stringsDict = [self getTranslationStringsDictFromLprogAtPath:[TWNStringsTests.bundleRoot stringByAppendingPathComponent:lprojFileName]]; for (NSString *key in stringsDict) { // Keys use dash "-" separators. XCTAssertFalse([key containsString:@"_"]); } } } // Translators need context for all substitutions. If a string has any substitutions, such as "$1" or "$2" etc, the string's comment needs to explain what will be substituted in place of "$1", "$2" etc. - (void)testiOSTranslationCommentsForMentionOfEachSubstitution { NSDictionary *enStrings = [self getDictFromPListAtPath:[[TWNStringsTests.twnLocalizationsDirectory stringByAppendingPathComponent:@"en.lproj"] stringByAppendingPathComponent:@"Localizable.strings"]]; NSDictionary *qqqStrings = [self getDictFromPListAtPath:[[TWNStringsTests.twnLocalizationsDirectory stringByAppendingPathComponent:@"qqq.lproj"] stringByAppendingPathComponent:@"Localizable.strings"]]; for (NSString *enKey in enStrings) { // This test assumes each EN key is also present in QQQ. XCTAssertTrue([qqqStrings valueForKey:enKey], @"Expected en key in qqq"); NSString *enString = enStrings[enKey]; NSString *qqqString = qqqStrings[enKey]; NSArray *enSubstitutionMatches = [TWNStringsTests.twnTokenRegex matchesInString:enString options:0 range:NSMakeRange(0, enString.length)]; NSArray *qqqSubstitutionMatches = [TWNStringsTests.twnTokenRegex matchesInString:qqqString options:0 range:NSMakeRange(0, qqqString.length)]; for (NSTextCheckingResult *enMatch in enSubstitutionMatches) { NSString *enMatchString = [enString substringWithRange:enMatch.range]; BOOL didFindEnMatchStringAtLeastOnceInQQQMatchString = NO; for (NSTextCheckingResult *qqqMatch in qqqSubstitutionMatches) { NSString *qqqMatchString = [qqqString substringWithRange:qqqMatch.range]; if ([qqqMatchString isEqualToString:enMatchString]) { didFindEnMatchStringAtLeastOnceInQQQMatchString = YES; break; } } XCTAssertTrue(didFindEnMatchStringAtLeastOnceInQQQMatchString, @"\n\tExpected each substitution (i.e. \"$1\") in string is mentioned at least once in its comment.\n\t\tString: \"%@\"\n\t\tComment: \"%@\"\n\t\tKey: \"%@\"\n\n", [enString stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"], [qqqString stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"], enKey); if (!didFindEnMatchStringAtLeastOnceInQQQMatchString) { // No need keep testing if a string already failed our assertion once. break; } } } } // Translators have been know to add "{{plural..." syntax to strings which don't yet have "{{plural..." in EN, which means the string won't be correctly resolved. - (void)testIncomingTranslationStringForBracketSubstitutionsNotPresentInEN { NSDictionary *enPluralizableStringsDict = [self getPluralizableStringsDictFromLprogAtPath:[TWNStringsTests.bundleRoot stringByAppendingPathComponent:@"en.lproj"]]; for (NSString *lprojFileName in TWNStringsTests.twnLprojFiles) { if (![lprojFileName isEqualToString:@"qqq.lproj"] && ![lprojFileName isEqualToString:@"en.lproj"]) { NSDictionary *translationPluralizableStringsDict = [self getPluralizableStringsDictFromLprogAtPath:[TWNStringsTests.bundleRoot stringByAppendingPathComponent:lprojFileName]]; for (NSString *key in translationPluralizableStringsDict) { XCTAssertNotNil([enPluralizableStringsDict objectForKey:key], @"\n\n\"%@\" translation containing plurals syntax received for \"%@\" string. The original EN string...\n\thttps://translatewiki.net/w/i.php?title=Wikimedia:Wikipedia-ios-%@/en&action=edit\n...doesn't have (or possibly need) plural syntax - either plural syntax will need to be added to the EN string or the translation...\n\thttps://translatewiki.net/w/i.php?title=Wikimedia:Wikipedia-ios-%@/%@&action=edit\n...will need to be updated to remove plural syntax.\n(Note: after loading the link above you can tap the \"Ask question\" button to pre-fill a Phabricator ticket for asking `i18n` folks for assistance for this string)\n\n", lprojFileName, key, key, key, [lprojFileName stringByReplacingOccurrencesOfString:@".lproj" withString:@""]); } } } } - (void)tearDown { [super tearDown]; } @end