[PowerRename] Support using photo metadata to replace in the PowerRename (#41728)

<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
1. Introduce WIC for power rename and add new class WICMetadataExtractor
to use WIC to extract metadata.
2. Add some patterns for metadata extract.
3. Support XMP and EXIF metadata extract.
4. Add test data for xmp and exif extractor
5. Add attribution for the test data uploader.

UI:
<img width="2052" height="1415" alt="image"
src="https://github.com/user-attachments/assets/9051b12e-4e66-4fdc-a4d4-3bada661c235"
/>
<img width="284" height="170" alt="image"
src="https://github.com/user-attachments/assets/2fd67193-77a7-48f0-a5ac-08a69fe64e55"
/>
<img width="715" height="1160" alt="image"
src="https://github.com/user-attachments/assets/5fa68a8c-d129-44dd-b747-099dfbcded12"
/>

demo:


https://github.com/user-attachments/assets/e90bc206-62e5-4101-ada2-3187ee7e2039



<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #5612
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [x] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

---------

Co-authored-by: Yu Leng <yuleng@microsoft.com>
This commit is contained in:
moooyo
2025-11-04 09:27:16 +08:00
committed by GitHub
parent 957b653210
commit 70e1177a6a
54 changed files with 4941 additions and 79 deletions

View File

@@ -0,0 +1,766 @@
#include "pch.h"
#include "Helpers.h"
#include "MetadataPatternExtractor.h"
#include "MetadataTypes.h"
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
namespace HelpersTests
{
TEST_CLASS(GetMetadataFileNameTests)
{
public:
TEST_METHOD(BasicPatternReplacement)
{
// Test basic pattern replacement with available metadata
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"CAMERA_MAKE"] = L"Canon";
patterns[L"ISO"] = L"ISO 400";
patterns[L"DATE_TAKEN_YYYY"] = L"2024";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE_$ISO", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"photo_Canon_ISO 400", result);
}
TEST_METHOD(PatternWithoutValueShowsPatternName)
{
// Test that patterns without values show the pattern name with $ prefix
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"CAMERA_MAKE"] = L"Canon";
// ISO is not in the map
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE_$ISO", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"photo_Canon_$ISO", result);
}
TEST_METHOD(EmptyPatternShowsPatternName)
{
// Test that patterns with empty value show the pattern name with $ prefix
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"CAMERA_MAKE"] = L"Canon";
patterns[L"ISO"] = L"";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE_$ISO", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"photo_Canon_$ISO", result);
}
TEST_METHOD(EscapedDollarSigns)
{
// Test that $$ is converted to single $
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"ISO"] = L"ISO 400";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$$_$ISO", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"photo_$_ISO 400", result);
}
TEST_METHOD(MultipleEscapedDollarSigns)
{
// Test that $$$$ is converted to $$
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"ISO"] = L"ISO 400";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$$$$price", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"photo_$$price", result);
}
TEST_METHOD(OddDollarSignsWithPattern)
{
// Test that $$$ becomes $ followed by pattern
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"ISO"] = L"ISO 400";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$$$ISO", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"photo_$ISO 400", result);
}
TEST_METHOD(LongestPatternMatchPriority)
{
// Test that longer patterns are matched first (DATE_TAKEN_YYYY vs DATE_TAKEN_YY)
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"DATE_TAKEN_YYYY"] = L"2024";
patterns[L"DATE_TAKEN_YY"] = L"24";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$DATE_TAKEN_YYYY", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"photo_2024", result);
}
TEST_METHOD(MultiplePatterns)
{
// Test multiple patterns in one string
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"CAMERA_MAKE"] = L"Canon";
patterns[L"CAMERA_MODEL"] = L"EOS R5";
patterns[L"ISO"] = L"ISO 800";
patterns[L"DATE_TAKEN_YYYY"] = L"2024";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH,
L"$DATE_TAKEN_YYYY-$CAMERA_MAKE-$CAMERA_MODEL-$ISO", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"2024-Canon-EOS R5-ISO 800", result);
}
TEST_METHOD(UnrecognizedPatternIgnored)
{
// Test that unrecognized patterns are not replaced
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"CAMERA_MAKE"] = L"Canon";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE_$INVALID_PATTERN", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"photo_Canon_$INVALID_PATTERN", result);
}
TEST_METHOD(NoPatterns)
{
// Test string with no patterns
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"CAMERA_MAKE"] = L"Canon";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_name_without_patterns", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"photo_name_without_patterns", result);
}
TEST_METHOD(EmptyInput)
{
// Test with empty input string
PowerRenameLib::MetadataPatternMap patterns;
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"", patterns);
Assert::IsTrue(FAILED(hr));
}
TEST_METHOD(NullInput)
{
// Test with null input
PowerRenameLib::MetadataPatternMap patterns;
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, nullptr, patterns);
Assert::IsTrue(FAILED(hr));
}
TEST_METHOD(DollarAtEnd)
{
// Test dollar sign at the end of string
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"ISO"] = L"ISO 400";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$ISO$", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"photo_ISO 400$", result);
}
TEST_METHOD(ThreeDollarsAtEnd)
{
// Test three dollar signs at the end
PowerRenameLib::MetadataPatternMap patterns;
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo$$$", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"photo$$$", result);
}
TEST_METHOD(ComplexMixedScenario)
{
// Test complex scenario with mixed patterns, escapes, and regular text
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"CAMERA_MAKE"] = L"Canon";
patterns[L"ISO"] = L"ISO 400";
patterns[L"APERTURE"] = L"f/2.8";
patterns[L"LENS"] = L""; // Empty value
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH,
L"$$price_$CAMERA_MAKE_$$$ISO_$APERTURE_$LENS_$$end", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"$price_Canon_$ISO 400_f/2.8_$LENS_$end", result);
}
TEST_METHOD(AllEXIFPatterns)
{
// Test with various EXIF patterns
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"WIDTH"] = L"4000";
patterns[L"HEIGHT"] = L"3000";
patterns[L"FOCAL"] = L"50mm";
patterns[L"SHUTTER"] = L"1/100s";
patterns[L"FLASH"] = L"Flash Off";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH,
L"photo_$WIDTH x $HEIGHT_$FOCAL_$SHUTTER_$FLASH", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"photo_4000 x 3000_50mm_1/100s_Flash Off", result);
}
TEST_METHOD(AllXMPPatterns)
{
// Test with various XMP patterns
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"TITLE"] = L"Sunset";
patterns[L"CREATOR"] = L"John Doe";
patterns[L"DESCRIPTION"] = L"Beautiful sunset";
patterns[L"CREATE_DATE_YYYY"] = L"2024";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH,
L"$CREATE_DATE_YYYY-$TITLE-by-$CREATOR", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"2024-Sunset-by-John Doe", result);
}
TEST_METHOD(DateComponentPatterns)
{
// Test date component patterns
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"DATE_TAKEN_YYYY"] = L"2024";
patterns[L"DATE_TAKEN_MM"] = L"03";
patterns[L"DATE_TAKEN_DD"] = L"15";
patterns[L"DATE_TAKEN_HH"] = L"14";
patterns[L"DATE_TAKEN_mm"] = L"30";
patterns[L"DATE_TAKEN_SS"] = L"45";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH,
L"photo_$DATE_TAKEN_YYYY-$DATE_TAKEN_MM-$DATE_TAKEN_DD_$DATE_TAKEN_HH-$DATE_TAKEN_mm-$DATE_TAKEN_SS",
patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"photo_2024-03-15_14-30-45", result);
}
TEST_METHOD(SpecialCharactersInValues)
{
// Test that special characters in metadata values are preserved
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"TITLE"] = L"Photo (with) [brackets] & symbols!";
patterns[L"DESCRIPTION"] = L"Test: value; with, punctuation.";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH,
L"$TITLE - $DESCRIPTION", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"Photo (with) [brackets] & symbols! - Test: value; with, punctuation.", result);
}
TEST_METHOD(ConsecutivePatternsWithoutSeparator)
{
// Test consecutive patterns without separator
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"CAMERA_MAKE"] = L"Canon";
patterns[L"CAMERA_MODEL"] = L"R5";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$CAMERA_MAKE$CAMERA_MODEL", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"CanonR5", result);
}
TEST_METHOD(PatternAtStart)
{
// Test pattern at the beginning of string
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"CAMERA_MAKE"] = L"Canon";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$CAMERA_MAKE_photo", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"Canon_photo", result);
}
TEST_METHOD(PatternAtEnd)
{
// Test pattern at the end of string
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"CAMERA_MAKE"] = L"Canon";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"photo_Canon", result);
}
TEST_METHOD(OnlyPattern)
{
// Test string with only a pattern
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"CAMERA_MAKE"] = L"Canon";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$CAMERA_MAKE", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"Canon", result);
}
};
TEST_CLASS(PatternMatchingTests)
{
public:
TEST_METHOD(VerifyLongestPatternMatching)
{
// This test verifies the greedy matching behavior
// When we have overlapping pattern names, the longest should be matched first
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"DATE_TAKEN_Y"] = L"4";
patterns[L"DATE_TAKEN_YY"] = L"24";
patterns[L"DATE_TAKEN_YYYY"] = L"2024";
wchar_t result[MAX_PATH] = { 0 };
// Should match YYYY (longest)
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$DATE_TAKEN_YYYY", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"2024", result);
// Should match YY (available pattern)
hr = GetMetadataFileName(result, MAX_PATH, L"$DATE_TAKEN_YY", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"24", result);
}
TEST_METHOD(PartialPatternNames)
{
// Test that partial pattern names don't match longer patterns
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"CAMERA_MODEL"] = L"EOS R5";
wchar_t result[MAX_PATH] = { 0 };
// CAMERA is not a valid pattern, should not match
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$CAMERA_MODEL", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"EOS R5", result);
}
TEST_METHOD(CaseSensitivePatterns)
{
// Test that pattern names are case-sensitive
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"CAMERA_MAKE"] = L"Canon";
wchar_t result[MAX_PATH] = { 0 };
// lowercase should not match
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$camera_make", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"$camera_make", result); // Not replaced
}
TEST_METHOD(EmptyPatternMap)
{
// Test with empty pattern map
PowerRenameLib::MetadataPatternMap patterns; // Empty
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$ISO_$CAMERA_MAKE", patterns);
Assert::IsTrue(SUCCEEDED(hr));
// Patterns should show with $ prefix since they're valid but have no values
Assert::AreEqual(L"photo_$ISO_$CAMERA_MAKE", result);
}
};
TEST_CLASS(EdgeCaseTests)
{
public:
TEST_METHOD(VeryLongString)
{
// Test with a very long input string
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"CAMERA_MAKE"] = L"Canon";
std::wstring longInput = L"prefix_";
for (int i = 0; i < 100; i++)
{
longInput += L"$CAMERA_MAKE_";
}
wchar_t result[4096] = { 0 };
HRESULT hr = GetMetadataFileName(result, 4096, longInput.c_str(), patterns);
Assert::IsTrue(SUCCEEDED(hr));
// Verify it starts correctly
Assert::IsTrue(wcsstr(result, L"prefix_Canon_") == result);
}
TEST_METHOD(ManyConsecutiveDollars)
{
// Test with many consecutive dollar signs
PowerRenameLib::MetadataPatternMap patterns;
wchar_t result[MAX_PATH] = { 0 };
// 8 dollars should become 4 dollars
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo$$$$$$$$name", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"photo$$$$name", result);
}
TEST_METHOD(OnlyDollars)
{
// Test string with only dollar signs
PowerRenameLib::MetadataPatternMap patterns;
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$$$$", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"$$", result);
}
TEST_METHOD(UnicodeCharacters)
{
// Test with unicode characters in pattern values
PowerRenameLib::MetadataPatternMap patterns;
patterns[L"TITLE"] = L"照片_фото_φωτογραφία";
patterns[L"CREATOR"] = L"张三_Иван_Γιάννης";
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$TITLE-$CREATOR", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"照片_фото_φωτογραφία-张三_Иван_Γιάννης", result);
}
TEST_METHOD(SingleDollar)
{
// Test with single dollar not followed by pattern
PowerRenameLib::MetadataPatternMap patterns;
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"price$100", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"price$100", result);
}
TEST_METHOD(DollarFollowedByNumber)
{
// Test dollar followed by numbers (not a pattern)
PowerRenameLib::MetadataPatternMap patterns;
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"cost_$123.45", patterns);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"cost_$123.45", result);
}
};
TEST_CLASS(GetDatedFileNameTests)
{
public:
// Helper to get a fixed test time for consistent testing
SYSTEMTIME GetTestTime()
{
SYSTEMTIME testTime = { 0 };
testTime.wYear = 2024;
testTime.wMonth = 3; // March
testTime.wDay = 15; // 15th
testTime.wHour = 14; // 2 PM (24-hour format)
testTime.wMinute = 30;
testTime.wSecond = 45;
testTime.wMilliseconds = 123;
testTime.wDayOfWeek = 5; // Friday (0=Sunday, 5=Friday)
return testTime;
}
// Category 1: Tests for invalid patterns with extra characters (verify negative lookahead prevents wrong matching)
TEST_METHOD(InvalidPattern_YYY_NotMatched)
{
// Test $YYY (3 Y's) is not a valid pattern and should remain unchanged
// Negative lookahead in $YY(?!Y) prevents matching $YYY
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$YYY", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file_$YYY", result); // $YYY is invalid, should remain unchanged
}
TEST_METHOD(InvalidPattern_DDD_NotPartiallyMatched)
{
// Test that $DDD (short weekday) is not confused with $DD (2-digit day)
// This verifies negative lookahead works correctly
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDD", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file_Fri", result); // Should be "Fri", not "15D"
}
TEST_METHOD(InvalidPattern_MMM_NotPartiallyMatched)
{
// Test that $MMM (short month name) is not confused with $MM (2-digit month)
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$MMM", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file_Mar", result); // Should be "Mar", not "03M"
}
TEST_METHOD(InvalidPattern_HHH_NotMatched)
{
// Test $HHH (3 H's) is not valid and negative lookahead prevents $HH from matching
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$HHH", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file_$HHH", result); // Should remain unchanged
}
TEST_METHOD(SeparatedPatterns_SingleY)
{
// Test multiple $Y with separators works correctly
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$Y-$Y-$Y", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file_4-4-4", result); // Each $Y outputs "4" (from 2024)
}
TEST_METHOD(SeparatedPatterns_SingleD)
{
// Test multiple $D with separators works correctly
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$D.$D.$D", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file_15.15.15", result); // Each $D outputs "15"
}
// Category 2: Tests for mixed length patterns (verify longer patterns don't get matched incorrectly)
TEST_METHOD(MixedLengthYear_QuadFollowedBySingle)
{
// Test $YYYY$Y - should be 2024 + 4
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$YYYY$Y", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file_20244", result);
}
TEST_METHOD(MixedLengthDay_TripleFollowedBySingle)
{
// Test $DDD$D - should be "Fri" + "15"
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDD$D", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file_Fri15", result);
}
TEST_METHOD(MixedLengthDay_QuadFollowedByDouble)
{
// Test $DDDD$DD - should be "Friday" + "15"
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDDD$DD", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file_Friday15", result);
}
TEST_METHOD(MixedLengthMonth_TripleFollowedBySingle)
{
// Test $MMM$M - should be "Mar" + "3"
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$MMM$M", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file_Mar3", result);
}
// Category 3: Tests for boundary conditions (patterns at start, end, with special chars)
TEST_METHOD(PatternAtStart)
{
// Test pattern at the very start of filename
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"$YYYY$M$D", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"2024315", result);
}
TEST_METHOD(PatternAtEnd)
{
// Test pattern at the very end of filename
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$Y", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file_4", result);
}
TEST_METHOD(PatternWithSpecialChars)
{
// Test patterns surrounded by special characters
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file-$Y.$Y-$M", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file-4.4-3", result);
}
TEST_METHOD(EmptyFileName)
{
// Test with empty input string - should return E_INVALIDARG
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"", testTime);
Assert::IsTrue(FAILED(hr)); // Empty string should fail
Assert::AreEqual(E_INVALIDARG, hr);
}
// Category 4: Tests to explicitly verify negative lookahead is working
TEST_METHOD(NegativeLookahead_YearNotMatchedInYYYY)
{
// Verify $Y doesn't match when part of $YYYY
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$YYYY", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file_2024", result); // Should be "2024", not "202Y"
}
TEST_METHOD(NegativeLookahead_MonthNotMatchedInMMM)
{
// Verify $M doesn't match when part of $MMM
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$MMM", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file_Mar", result); // Should be "Mar", not "3ar"
}
TEST_METHOD(NegativeLookahead_DayNotMatchedInDDDD)
{
// Verify $D doesn't match when part of $DDDD
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDDD", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file_Friday", result); // Should be "Friday", not "15riday"
}
TEST_METHOD(NegativeLookahead_HourNotMatchedInHH)
{
// Verify $H doesn't match when part of $HH
// Note: $HH is 12-hour format, so 14:00 (2 PM) displays as "02"
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$HH", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file_02", result); // 14:00 in 12-hour format is "02 PM"
}
TEST_METHOD(NegativeLookahead_MillisecondNotMatchedInFFF)
{
// Verify $f doesn't match when part of $fff
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$fff", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"file_123", result); // Should be "123", not "1ff"
}
// Category 5: Complex mixed scenarios
TEST_METHOD(ComplexMixedPattern_AllFormats)
{
// Test a complex realistic filename with mixed pattern lengths
// Note: Using $hh for 24-hour format instead of $HH (which is 12-hour)
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"Photo_$YYYY-$MM-$DD_$hh-$mm-$ss_$fff", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"Photo_2024-03-15_14-30-45_123", result);
}
TEST_METHOD(ComplexMixedPattern_WithSeparators)
{
// Test multiple patterns of different lengths with separators
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"$YYYY_$Y-$Y_$MM_$M", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"2024_4-4_03_3", result);
}
TEST_METHOD(ComplexMixedPattern_DayFormats)
{
// Test all day format variations in one string
SYSTEMTIME testTime = GetTestTime();
wchar_t result[MAX_PATH] = { 0 };
HRESULT hr = GetDatedFileName(result, MAX_PATH, L"$D-$DD-$DDD-$DDDD", testTime);
Assert::IsTrue(SUCCEEDED(hr));
Assert::AreEqual(L"15-15-Fri-Friday", result);
}
};
}

View File

@@ -0,0 +1,487 @@
#include "pch.h"
#include "MetadataFormatHelper.h"
#include <cmath>
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
using namespace PowerRenameLib;
namespace MetadataFormatHelperTests
{
TEST_CLASS(FormatApertureTests)
{
public:
TEST_METHOD(FormatAperture_ValidValue)
{
// Test formatting a typical aperture value
std::wstring result = MetadataFormatHelper::FormatAperture(2.8);
Assert::AreEqual(L"f/2.8", result.c_str());
}
TEST_METHOD(FormatAperture_SmallValue)
{
// Test small aperture (large f-number)
std::wstring result = MetadataFormatHelper::FormatAperture(1.4);
Assert::AreEqual(L"f/1.4", result.c_str());
}
TEST_METHOD(FormatAperture_LargeValue)
{
// Test large aperture (small f-number)
std::wstring result = MetadataFormatHelper::FormatAperture(22.0);
Assert::AreEqual(L"f/22.0", result.c_str());
}
TEST_METHOD(FormatAperture_RoundedValue)
{
// Test rounding to one decimal place
std::wstring result = MetadataFormatHelper::FormatAperture(5.66666);
Assert::AreEqual(L"f/5.7", result.c_str());
}
TEST_METHOD(FormatAperture_Zero)
{
// Test zero value
std::wstring result = MetadataFormatHelper::FormatAperture(0.0);
Assert::AreEqual(L"f/0.0", result.c_str());
}
};
TEST_CLASS(FormatShutterSpeedTests)
{
public:
TEST_METHOD(FormatShutterSpeed_FastSpeed)
{
// Test fast shutter speed (fraction of second)
std::wstring result = MetadataFormatHelper::FormatShutterSpeed(0.002);
Assert::AreEqual(L"1/500s", result.c_str());
}
TEST_METHOD(FormatShutterSpeed_VeryFastSpeed)
{
// Test very fast shutter speed
std::wstring result = MetadataFormatHelper::FormatShutterSpeed(0.0001);
Assert::AreEqual(L"1/10000s", result.c_str());
}
TEST_METHOD(FormatShutterSpeed_SlowSpeed)
{
// Test slow shutter speed (more than 1 second)
std::wstring result = MetadataFormatHelper::FormatShutterSpeed(2.5);
Assert::AreEqual(L"2.5s", result.c_str());
}
TEST_METHOD(FormatShutterSpeed_OneSecond)
{
// Test exactly 1 second
std::wstring result = MetadataFormatHelper::FormatShutterSpeed(1.0);
Assert::AreEqual(L"1.0s", result.c_str());
}
TEST_METHOD(FormatShutterSpeed_VerySlowSpeed)
{
// Test very slow shutter speed (< 1 second but close)
std::wstring result = MetadataFormatHelper::FormatShutterSpeed(0.5);
Assert::AreEqual(L"1/2s", result.c_str());
}
TEST_METHOD(FormatShutterSpeed_Zero)
{
// Test zero value
std::wstring result = MetadataFormatHelper::FormatShutterSpeed(0.0);
Assert::AreEqual(L"0", result.c_str());
}
TEST_METHOD(FormatShutterSpeed_Negative)
{
// Test negative value (invalid but should handle gracefully)
std::wstring result = MetadataFormatHelper::FormatShutterSpeed(-1.0);
Assert::AreEqual(L"0", result.c_str());
}
};
TEST_CLASS(FormatISOTests)
{
public:
TEST_METHOD(FormatISO_TypicalValue)
{
// Test typical ISO value
std::wstring result = MetadataFormatHelper::FormatISO(400);
Assert::AreEqual(L"ISO 400", result.c_str());
}
TEST_METHOD(FormatISO_LowValue)
{
// Test low ISO value
std::wstring result = MetadataFormatHelper::FormatISO(100);
Assert::AreEqual(L"ISO 100", result.c_str());
}
TEST_METHOD(FormatISO_HighValue)
{
// Test high ISO value
std::wstring result = MetadataFormatHelper::FormatISO(12800);
Assert::AreEqual(L"ISO 12800", result.c_str());
}
TEST_METHOD(FormatISO_Zero)
{
// Test zero value
std::wstring result = MetadataFormatHelper::FormatISO(0);
Assert::AreEqual(L"ISO", result.c_str());
}
TEST_METHOD(FormatISO_Negative)
{
// Test negative value (invalid but should handle gracefully)
std::wstring result = MetadataFormatHelper::FormatISO(-100);
Assert::AreEqual(L"ISO", result.c_str());
}
};
TEST_CLASS(FormatFlashTests)
{
public:
TEST_METHOD(FormatFlash_Off)
{
// Test flash off (bit 0 = 0)
std::wstring result = MetadataFormatHelper::FormatFlash(0x0);
Assert::AreEqual(L"Flash Off", result.c_str());
}
TEST_METHOD(FormatFlash_On)
{
// Test flash on (bit 0 = 1)
std::wstring result = MetadataFormatHelper::FormatFlash(0x1);
Assert::AreEqual(L"Flash On", result.c_str());
}
TEST_METHOD(FormatFlash_OnWithAdditionalFlags)
{
// Test flash on with additional flags
std::wstring result = MetadataFormatHelper::FormatFlash(0x5); // 0b0101 = fired, return detected
Assert::AreEqual(L"Flash On", result.c_str());
}
TEST_METHOD(FormatFlash_OffWithAdditionalFlags)
{
// Test flash off with additional flags
std::wstring result = MetadataFormatHelper::FormatFlash(0x10); // Bit 0 is 0
Assert::AreEqual(L"Flash Off", result.c_str());
}
};
TEST_CLASS(FormatCoordinateTests)
{
public:
TEST_METHOD(FormatCoordinate_NorthLatitude)
{
// Test north latitude
std::wstring result = MetadataFormatHelper::FormatCoordinate(40.7128, true);
Assert::AreEqual(L"40°42.77'N", result.c_str());
}
TEST_METHOD(FormatCoordinate_SouthLatitude)
{
// Test south latitude
std::wstring result = MetadataFormatHelper::FormatCoordinate(-33.8688, true);
Assert::AreEqual(L"33°52.13'S", result.c_str());
}
TEST_METHOD(FormatCoordinate_EastLongitude)
{
// Test east longitude
std::wstring result = MetadataFormatHelper::FormatCoordinate(151.2093, false);
Assert::AreEqual(L"151°12.56'E", result.c_str());
}
TEST_METHOD(FormatCoordinate_WestLongitude)
{
// Test west longitude
std::wstring result = MetadataFormatHelper::FormatCoordinate(-74.0060, false);
Assert::AreEqual(L"74°0.36'W", result.c_str());
}
TEST_METHOD(FormatCoordinate_ZeroLatitude)
{
// Test equator (0 degrees latitude)
std::wstring result = MetadataFormatHelper::FormatCoordinate(0.0, true);
Assert::AreEqual(L"0°0.00'N", result.c_str());
}
TEST_METHOD(FormatCoordinate_ZeroLongitude)
{
// Test prime meridian (0 degrees longitude)
std::wstring result = MetadataFormatHelper::FormatCoordinate(0.0, false);
Assert::AreEqual(L"0°0.00'E", result.c_str());
}
};
TEST_CLASS(FormatSystemTimeTests)
{
public:
TEST_METHOD(FormatSystemTime_ValidDateTime)
{
// Test formatting a valid date and time
SYSTEMTIME st = { 0 };
st.wYear = 2024;
st.wMonth = 3;
st.wDay = 15;
st.wHour = 14;
st.wMinute = 30;
st.wSecond = 45;
std::wstring result = MetadataFormatHelper::FormatSystemTime(st);
Assert::AreEqual(L"2024-03-15 14:30:45", result.c_str());
}
TEST_METHOD(FormatSystemTime_Midnight)
{
// Test midnight time
SYSTEMTIME st = { 0 };
st.wYear = 2024;
st.wMonth = 1;
st.wDay = 1;
st.wHour = 0;
st.wMinute = 0;
st.wSecond = 0;
std::wstring result = MetadataFormatHelper::FormatSystemTime(st);
Assert::AreEqual(L"2024-01-01 00:00:00", result.c_str());
}
TEST_METHOD(FormatSystemTime_EndOfDay)
{
// Test end of day time
SYSTEMTIME st = { 0 };
st.wYear = 2024;
st.wMonth = 12;
st.wDay = 31;
st.wHour = 23;
st.wMinute = 59;
st.wSecond = 59;
std::wstring result = MetadataFormatHelper::FormatSystemTime(st);
Assert::AreEqual(L"2024-12-31 23:59:59", result.c_str());
}
};
TEST_CLASS(ParseSingleRationalTests)
{
public:
TEST_METHOD(ParseSingleRational_ValidValue)
{
// Test parsing a valid rational: 5/2 = 2.5
uint8_t bytes[] = { 5, 0, 0, 0, 2, 0, 0, 0 };
double result = MetadataFormatHelper::ParseSingleRational(bytes, 0);
Assert::AreEqual(2.5, result, 0.001);
}
TEST_METHOD(ParseSingleRational_IntegerResult)
{
// Test parsing rational that results in integer: 10/5 = 2.0
uint8_t bytes[] = { 10, 0, 0, 0, 5, 0, 0, 0 };
double result = MetadataFormatHelper::ParseSingleRational(bytes, 0);
Assert::AreEqual(2.0, result, 0.001);
}
TEST_METHOD(ParseSingleRational_LargeNumerator)
{
// Test parsing with large numerator: 1000/100 = 10.0
uint8_t bytes[] = { 0xE8, 0x03, 0, 0, 100, 0, 0, 0 }; // 1000 in little-endian
double result = MetadataFormatHelper::ParseSingleRational(bytes, 0);
Assert::AreEqual(10.0, result, 0.001);
}
TEST_METHOD(ParseSingleRational_ZeroDenominator)
{
// Test parsing with zero denominator (should return 0.0)
uint8_t bytes[] = { 5, 0, 0, 0, 0, 0, 0, 0 };
double result = MetadataFormatHelper::ParseSingleRational(bytes, 0);
Assert::AreEqual(0.0, result, 0.001);
}
TEST_METHOD(ParseSingleRational_ZeroNumerator)
{
// Test parsing with zero numerator: 0/5 = 0.0
uint8_t bytes[] = { 0, 0, 0, 0, 5, 0, 0, 0 };
double result = MetadataFormatHelper::ParseSingleRational(bytes, 0);
Assert::AreEqual(0.0, result, 0.001);
}
TEST_METHOD(ParseSingleRational_WithOffset)
{
// Test parsing with offset
uint8_t bytes[] = { 0xFF, 0xFF, 0xFF, 0xFF, 10, 0, 0, 0, 5, 0, 0, 0 }; // Offset = 4
double result = MetadataFormatHelper::ParseSingleRational(bytes, 4);
Assert::AreEqual(2.0, result, 0.001);
}
TEST_METHOD(ParseSingleRational_NullPointer)
{
// Test with null pointer (should return 0.0)
double result = MetadataFormatHelper::ParseSingleRational(nullptr, 0);
Assert::AreEqual(0.0, result, 0.001);
}
};
TEST_CLASS(ParseSingleSRationalTests)
{
public:
TEST_METHOD(ParseSingleSRational_PositiveValue)
{
// Test parsing positive signed rational: 5/2 = 2.5
uint8_t bytes[] = { 5, 0, 0, 0, 2, 0, 0, 0 };
double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0);
Assert::AreEqual(2.5, result, 0.001);
}
TEST_METHOD(ParseSingleSRational_NegativeNumerator)
{
// Test parsing negative numerator: -5/2 = -2.5
uint8_t bytes[] = { 0xFB, 0xFF, 0xFF, 0xFF, 2, 0, 0, 0 }; // -5 in two's complement
double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0);
Assert::AreEqual(-2.5, result, 0.001);
}
TEST_METHOD(ParseSingleSRational_NegativeDenominator)
{
// Test parsing negative denominator: 5/-2 = -2.5
uint8_t bytes[] = { 5, 0, 0, 0, 0xFE, 0xFF, 0xFF, 0xFF }; // -2 in two's complement
double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0);
Assert::AreEqual(-2.5, result, 0.001);
}
TEST_METHOD(ParseSingleSRational_BothNegative)
{
// Test parsing both negative: -5/-2 = 2.5
uint8_t bytes[] = { 0xFB, 0xFF, 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFF };
double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0);
Assert::AreEqual(2.5, result, 0.001);
}
TEST_METHOD(ParseSingleSRational_ExposureBias)
{
// Test typical exposure bias value: -1/3 ≈ -0.333
uint8_t bytes[] = { 0xFF, 0xFF, 0xFF, 0xFF, 3, 0, 0, 0 }; // -1/3
double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0);
Assert::AreEqual(-0.333, result, 0.001);
}
TEST_METHOD(ParseSingleSRational_ZeroDenominator)
{
// Test with zero denominator (should return 0.0)
uint8_t bytes[] = { 5, 0, 0, 0, 0, 0, 0, 0 };
double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0);
Assert::AreEqual(0.0, result, 0.001);
}
TEST_METHOD(ParseSingleSRational_NullPointer)
{
// Test with null pointer (should return 0.0)
double result = MetadataFormatHelper::ParseSingleSRational(nullptr, 0);
Assert::AreEqual(0.0, result, 0.001);
}
};
TEST_CLASS(SanitizeForFileNameTests)
{
public:
TEST_METHOD(SanitizeForFileName_ValidString)
{
// Test string without illegal characters
std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Canon EOS 5D");
Assert::AreEqual(L"Canon EOS 5D", result.c_str());
}
TEST_METHOD(SanitizeForFileName_WithColon)
{
// Test string with colon (illegal character)
std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Photo:001");
Assert::AreEqual(L"Photo_001", result.c_str());
}
TEST_METHOD(SanitizeForFileName_WithSlashes)
{
// Test string with forward and backward slashes
std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Photos/2024\\January");
Assert::AreEqual(L"Photos_2024_January", result.c_str());
}
TEST_METHOD(SanitizeForFileName_WithMultipleIllegalChars)
{
// Test string with multiple illegal characters
std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"<Test>:File|Name*?.txt");
Assert::AreEqual(L"_Test__File_Name__.txt", result.c_str());
}
TEST_METHOD(SanitizeForFileName_WithQuotes)
{
// Test string with quotes
std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Photo \"Best Shot\"");
Assert::AreEqual(L"Photo _Best Shot_", result.c_str());
}
TEST_METHOD(SanitizeForFileName_WithTrailingDot)
{
// Test string with trailing dot (should be removed)
std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"filename.");
Assert::AreEqual(L"filename", result.c_str());
}
TEST_METHOD(SanitizeForFileName_WithTrailingSpace)
{
// Test string with trailing space (should be removed)
std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"filename ");
Assert::AreEqual(L"filename", result.c_str());
}
TEST_METHOD(SanitizeForFileName_WithMultipleTrailingDotsAndSpaces)
{
// Test string with multiple trailing dots and spaces
std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"filename. . ");
Assert::AreEqual(L"filename", result.c_str());
}
TEST_METHOD(SanitizeForFileName_WithControlCharacters)
{
// Test string with control characters
std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"File\x01Name\x1F");
Assert::AreEqual(L"File_Name_", result.c_str());
}
TEST_METHOD(SanitizeForFileName_EmptyString)
{
// Test empty string
std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"");
Assert::AreEqual(L"", result.c_str());
}
TEST_METHOD(SanitizeForFileName_OnlyIllegalCharacters)
{
// Test string with only illegal characters
std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"<>:\"/\\|?*");
Assert::AreEqual(L"_________", result.c_str());
}
TEST_METHOD(SanitizeForFileName_OnlyTrailingCharacters)
{
// Test string with only dots and spaces (should return empty)
std::wstring result = MetadataFormatHelper::SanitizeForFileName(L". . ");
Assert::AreEqual(L"", result.c_str());
}
TEST_METHOD(SanitizeForFileName_UnicodeCharacters)
{
// Test string with valid Unicode characters
std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"照片_2024年");
Assert::AreEqual(L"照片_2024年", result.c_str());
}
TEST_METHOD(SanitizeForFileName_MixedContent)
{
// Test realistic metadata string with multiple issues
std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Copyright © 2024: John/Jane Doe. ");
Assert::AreEqual(L"Copyright © 2024_ John_Jane Doe", result.c_str());
}
};
}

View File

@@ -62,6 +62,12 @@ IFACEMETHODIMP CMockPowerRenameRegExEvents::OnFileTimeChanged(_In_ SYSTEMTIME fi
return S_OK;
}
IFACEMETHODIMP CMockPowerRenameRegExEvents::OnMetadataChanged()
{
return S_OK;
}
HRESULT CMockPowerRenameRegExEvents::s_CreateInstance(_Outptr_ IPowerRenameRegExEvents** ppsrree)
{
*ppsrree = nullptr;
@@ -74,3 +80,4 @@ HRESULT CMockPowerRenameRegExEvents::s_CreateInstance(_Outptr_ IPowerRenameRegEx
}
return hr;
}

View File

@@ -19,6 +19,7 @@ public:
IFACEMETHODIMP OnReplaceTermChanged(_In_ PCWSTR replaceTerm);
IFACEMETHODIMP OnFlagsChanged(_In_ DWORD flags);
IFACEMETHODIMP OnFileTimeChanged(_In_ SYSTEMTIME fileTime);
IFACEMETHODIMP OnMetadataChanged();
static HRESULT s_CreateInstance(_Outptr_ IPowerRenameRegExEvents** ppsrree);
@@ -39,3 +40,4 @@ public:
SYSTEMTIME m_fileTime = { 0 };
long m_refCount;
};

View File

@@ -34,7 +34,7 @@
</ClCompile>
<Link>
<AdditionalLibraryDirectories>$(VCInstallDir)UnitTest\lib;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories>
<AdditionalDependencies>$(OutDir)\..\..\WinUI3Apps\PowerRenameLib.lib;comctl32.lib;pathcch.lib;kernel32.lib;user32.lib;gdi32.lib;winspool.lib;comdlg32.lib;advapi32.lib;shell32.lib;ole32.lib;oleaut32.lib;uuid.lib;odbc32.lib;odbccp32.lib;Pathcch.lib;%(AdditionalDependencies)</AdditionalDependencies>
<AdditionalDependencies>$(OutDir)\..\..\WinUI3Apps\PowerRenameLib.lib;comctl32.lib;pathcch.lib;windowscodecs.lib;kernel32.lib;user32.lib;gdi32.lib;winspool.lib;comdlg32.lib;advapi32.lib;shell32.lib;ole32.lib;oleaut32.lib;uuid.lib;odbc32.lib;odbccp32.lib;Pathcch.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
@@ -49,11 +49,14 @@
<ClInclude Include="CommonRegExTests.h" />
</ItemGroup>
<ItemGroup>
<ClCompile Include="HelpersTests.cpp" />
<ClCompile Include="MockPowerRenameItem.cpp" />
<ClCompile Include="MockPowerRenameManagerEvents.cpp" />
<ClCompile Include="MockPowerRenameRegExEvents.cpp" />
<ClCompile Include="PowerRenameRegExBoostTests.cpp" />
<ClCompile Include="PowerRenameManagerTests.cpp" />
<ClCompile Include="MetadataFormatHelperTests.cpp" />
<ClCompile Include="WICMetadataExtractorTests.cpp" />
<ClCompile Include="pch.cpp">
<PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader>
</ClCompile>
@@ -73,8 +76,30 @@
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
<!-- Include all test data files for deployment -->
<None Include="testdata\exif_test.jpg">
<DeploymentContent>true</DeploymentContent>
</None>
<None Include="testdata\exif_test_2.jpg">
<DeploymentContent>true</DeploymentContent>
</None>
<None Include="testdata\xmp_test.jpg">
<DeploymentContent>true</DeploymentContent>
</None>
<None Include="testdata\xmp_test_2.jpg">
<DeploymentContent>true</DeploymentContent>
</None>
<None Include="testdata\ATTRIBUTION.md">
<DeploymentContent>true</DeploymentContent>
</None>
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<Target Name="CopyTestData" AfterTargets="Build">
<ItemGroup>
<TestDataFiles Include="$(ProjectDir)testdata\**\*.*" />
</ItemGroup>
<Copy SourceFiles="@(TestDataFiles)" DestinationFolder="$(OutDir)testdata\%(RecursiveDir)" SkipUnchangedFiles="true" />
</Target>
<ImportGroup Label="ExtensionTargets">
<Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" />
<Import Project="..\..\..\..\packages\boost.1.87.0\build\boost.targets" Condition="Exists('..\..\..\..\packages\boost.1.87.0\build\boost.targets')" />

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<ClCompile Include="HelpersTests.cpp" />
<ClCompile Include="MockPowerRenameItem.cpp" />
<ClCompile Include="MockPowerRenameManagerEvents.cpp" />
<ClCompile Include="MockPowerRenameRegExEvents.cpp" />
@@ -30,6 +31,9 @@
<Filter Include="Header Files">
<UniqueIdentifier>{d34a343a-52ef-4296-83c9-a94fa62062ff}</UniqueIdentifier>
</Filter>
<Filter Include="testdata">
<UniqueIdentifier>{8c9f3e2d-1a4b-4e5f-9c7d-2b8a6f3e1d4c}</UniqueIdentifier>
</Filter>
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="PowerRenameUnitTests.rc">
@@ -38,5 +42,20 @@
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
<None Include="testdata\exif_test.jpg">
<Filter>testdata</Filter>
</None>
<None Include="testdata\exif_test_2.jpg">
<Filter>testdata</Filter>
</None>
<None Include="testdata\xmp_test.jpg">
<Filter>testdata</Filter>
</None>
<None Include="testdata\xmp_test_2.jpg">
<Filter>testdata</Filter>
</None>
<None Include="testdata\ATTRIBUTION.md">
<Filter>testdata</Filter>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,244 @@
#include "pch.h"
#include "WICMetadataExtractor.h"
#include <filesystem>
#include <sstream>
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
using namespace PowerRenameLib;
namespace WICMetadataExtractorTests
{
// Helper function to get the test data directory path
std::wstring GetTestDataPath()
{
// Get the directory where the test DLL is located
// When running with vstest, we need to get the DLL module handle
HMODULE hModule = nullptr;
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(&GetTestDataPath),
&hModule);
wchar_t modulePath[MAX_PATH];
GetModuleFileNameW(hModule, modulePath, MAX_PATH);
std::filesystem::path dllPath(modulePath);
// Navigate to the test data directory
// The test data is in the output directory alongside the DLL
std::filesystem::path testDataPath = dllPath.parent_path() / L"testdata";
return testDataPath.wstring();
}
TEST_CLASS(ExtractEXIFMetadataTests)
{
public:
TEST_METHOD(ExtractEXIF_InvalidFile_ReturnsFalse)
{
// Test that EXIF extraction fails for nonexistent file
WICMetadataExtractor extractor;
EXIFMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\nonexistent.jpg";
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
Assert::IsFalse(result, L"EXIF extraction should fail for nonexistent file");
}
TEST_METHOD(ExtractEXIF_ExifTest_AllFields)
{
// Test exif_test.jpg which contains comprehensive EXIF data
WICMetadataExtractor extractor;
EXIFMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\exif_test.jpg";
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
Assert::IsTrue(result, L"EXIF extraction should succeed");
// Verify all the fields that are in exif_test.jpg
Assert::IsTrue(metadata.cameraMake.has_value(), L"Camera make should be present");
Assert::AreEqual(L"samsung", metadata.cameraMake.value().c_str(), L"Camera make should be samsung");
Assert::IsTrue(metadata.cameraModel.has_value(), L"Camera model should be present");
Assert::AreEqual(L"SM-G930P", metadata.cameraModel.value().c_str(), L"Camera model should be SM-G930P");
Assert::IsTrue(metadata.lensModel.has_value(), L"Lens model should be present");
Assert::AreEqual(L"Samsung Galaxy S7 Rear Camera", metadata.lensModel.value().c_str(), L"Lens model should match");
Assert::IsTrue(metadata.iso.has_value(), L"ISO should be present");
Assert::AreEqual(40, static_cast<int>(metadata.iso.value()), L"ISO should be 40");
Assert::IsTrue(metadata.aperture.has_value(), L"Aperture should be present");
Assert::AreEqual(1.7, metadata.aperture.value(), 0.01, L"Aperture should be f/1.7");
Assert::IsTrue(metadata.shutterSpeed.has_value(), L"Shutter speed should be present");
Assert::AreEqual(0.000625, metadata.shutterSpeed.value(), 0.000001, L"Shutter speed should be 0.000625s");
Assert::IsTrue(metadata.focalLength.has_value(), L"Focal length should be present");
Assert::AreEqual(4.2, metadata.focalLength.value(), 0.1, L"Focal length should be 4.2mm");
Assert::IsTrue(metadata.flash.has_value(), L"Flash should be present");
Assert::AreEqual(0u, static_cast<unsigned int>(metadata.flash.value()), L"Flash should be 0x0");
Assert::IsTrue(metadata.exposureBias.has_value(), L"Exposure bias should be present");
Assert::AreEqual(0.0, metadata.exposureBias.value(), 0.01, L"Exposure bias should be 0 EV");
Assert::IsTrue(metadata.author.has_value(), L"Author should be present");
Assert::AreEqual(L"Carl Seibert (Exif)", metadata.author.value().c_str(), L"Author should match");
Assert::IsTrue(metadata.copyright.has_value(), L"Copyright should be present");
Assert::IsTrue(metadata.copyright.value().find(L"Carl Seibert") != std::wstring::npos, L"Copyright should contain Carl Seibert");
}
TEST_METHOD(ExtractEXIF_ExifTest2_WidthHeight)
{
// Test exif_test_2.jpg which only contains width and height
WICMetadataExtractor extractor;
EXIFMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\exif_test_2.jpg";
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
Assert::IsTrue(result, L"EXIF extraction should succeed");
// exif_test_2.jpg only has width and height
Assert::IsTrue(metadata.width.has_value(), L"Width should be present");
Assert::AreEqual(1080u, static_cast<unsigned int>(metadata.width.value()), L"Width should be 1080px");
Assert::IsTrue(metadata.height.has_value(), L"Height should be present");
Assert::AreEqual(810u, static_cast<unsigned int>(metadata.height.value()), L"Height should be 810px");
// Other fields should not be present
Assert::IsFalse(metadata.cameraMake.has_value(), L"Camera make should not be present in exif_test_2.jpg");
Assert::IsFalse(metadata.cameraModel.has_value(), L"Camera model should not be present in exif_test_2.jpg");
Assert::IsFalse(metadata.iso.has_value(), L"ISO should not be present in exif_test_2.jpg");
}
TEST_METHOD(ExtractEXIF_ClearCache)
{
// Test cache clearing works
WICMetadataExtractor extractor;
EXIFMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\exif_test.jpg";
bool result1 = extractor.ExtractEXIFMetadata(testFile, metadata);
Assert::IsTrue(result1);
extractor.ClearCache();
EXIFMetadata metadata2;
bool result2 = extractor.ExtractEXIFMetadata(testFile, metadata2);
Assert::IsTrue(result2);
// Both calls should succeed
Assert::AreEqual(metadata.cameraMake.value().c_str(), metadata2.cameraMake.value().c_str());
}
};
TEST_CLASS(ExtractXMPMetadataTests)
{
public:
TEST_METHOD(ExtractXMP_InvalidFile_ReturnsFalse)
{
// Test that XMP extraction fails for nonexistent file
WICMetadataExtractor extractor;
XMPMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\nonexistent.jpg";
bool result = extractor.ExtractXMPMetadata(testFile, metadata);
Assert::IsFalse(result, L"XMP extraction should fail for nonexistent file");
}
TEST_METHOD(ExtractXMP_XmpTest_AllFields)
{
// Test xmp_test.jpg which contains comprehensive XMP data
WICMetadataExtractor extractor;
XMPMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\xmp_test.jpg";
bool result = extractor.ExtractXMPMetadata(testFile, metadata);
Assert::IsTrue(result, L"XMP extraction should succeed");
// Verify all the fields that are in xmp_test.jpg
Assert::IsTrue(metadata.title.has_value(), L"Title should be present");
Assert::AreEqual(L"object name here", metadata.title.value().c_str(), L"Title should match");
Assert::IsTrue(metadata.description.has_value(), L"Description should be present");
Assert::IsTrue(metadata.description.value().find(L"This is a metadata test file") != std::wstring::npos,
L"Description should contain expected text");
Assert::IsTrue(metadata.rights.has_value(), L"Rights should be present");
Assert::AreEqual(L"metadatamatters.blog", metadata.rights.value().c_str(), L"Rights should match");
Assert::IsTrue(metadata.creatorTool.has_value(), L"Creator tool should be present");
Assert::IsTrue(metadata.creatorTool.value().find(L"Adobe Photoshop Lightroom") != std::wstring::npos,
L"Creator tool should contain Lightroom");
Assert::IsTrue(metadata.documentID.has_value(), L"Document ID should be present");
Assert::IsTrue(metadata.documentID.value().find(L"xmp.did:") != std::wstring::npos,
L"Document ID should start with xmp.did:");
Assert::IsTrue(metadata.instanceID.has_value(), L"Instance ID should be present");
Assert::IsTrue(metadata.instanceID.value().find(L"xmp.iid:") != std::wstring::npos,
L"Instance ID should start with xmp.iid:");
Assert::IsTrue(metadata.subject.has_value(), L"Subject keywords should be present");
Assert::IsTrue(metadata.subject.value().size() > 0, L"Should have at least one keyword");
}
TEST_METHOD(ExtractXMP_XmpTest2_BasicFields)
{
// Test xmp_test_2.jpg which only contains basic XMP fields
WICMetadataExtractor extractor;
XMPMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\xmp_test_2.jpg";
bool result = extractor.ExtractXMPMetadata(testFile, metadata);
Assert::IsTrue(result, L"XMP extraction should succeed");
// xmp_test_2.jpg only has CreatorTool, DocumentID, and InstanceID
Assert::IsTrue(metadata.creatorTool.has_value(), L"Creator tool should be present");
Assert::IsTrue(metadata.creatorTool.value().find(L"Adobe Photoshop CS6") != std::wstring::npos,
L"Creator tool should be Photoshop CS6");
Assert::IsTrue(metadata.documentID.has_value(), L"Document ID should be present");
Assert::IsTrue(metadata.documentID.value().find(L"xmp.did:") != std::wstring::npos,
L"Document ID should start with xmp.did:");
Assert::IsTrue(metadata.instanceID.has_value(), L"Instance ID should be present");
Assert::IsTrue(metadata.instanceID.value().find(L"xmp.iid:") != std::wstring::npos,
L"Instance ID should start with xmp.iid:");
// Other fields should not be present
Assert::IsFalse(metadata.title.has_value(), L"Title should not be present in xmp_test_2.jpg");
Assert::IsFalse(metadata.description.has_value(), L"Description should not be present in xmp_test_2.jpg");
Assert::IsFalse(metadata.rights.has_value(), L"Rights should not be present in xmp_test_2.jpg");
Assert::IsFalse(metadata.creator.has_value(), L"Creator should not be present in xmp_test_2.jpg");
}
TEST_METHOD(ExtractXMP_ClearCache)
{
// Test cache clearing works
WICMetadataExtractor extractor;
XMPMetadata metadata;
std::wstring testFile = GetTestDataPath() + L"\\xmp_test.jpg";
bool result1 = extractor.ExtractXMPMetadata(testFile, metadata);
Assert::IsTrue(result1);
extractor.ClearCache();
XMPMetadata metadata2;
bool result2 = extractor.ExtractXMPMetadata(testFile, metadata2);
Assert::IsTrue(result2);
// Both calls should succeed
Assert::AreEqual(metadata.title.value().c_str(), metadata2.title.value().c_str());
}
};
}

View File

@@ -0,0 +1,45 @@
# Test Data Attribution
This directory contains test image files used for PowerRename metadata extraction unit tests. These images are sourced from Wikimedia Commons and are used under the Creative Commons licenses specified below.
## Test Files and Licenses
### Files from Carlseibert
**License:** [Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/)
- `exif_test.jpg` - Uploaded by [Carlseibert](https://commons.wikimedia.org/wiki/File%3AMetadata_test_file_-_includes_data_in_IIM%2C_XMP%2C_and_Exif.jpg) on Wikimedia Commons
- `xmp_test.jpg` - Uploaded by [Carlseibert](https://commons.wikimedia.org/wiki/File%3AMetadata_test_file_-_includes_data_in_IIM%2C_XMP%2C_and_Exif.jpg) on Wikimedia Commons
### Files from Edward Steven
**License:** [Creative Commons Attribution-ShareAlike 2.0 Generic (CC BY-SA 2.0)](https://creativecommons.org/licenses/by-sa/2.0/)
- `exif_test_2.jpg` - Uploaded by [Edward Steven](https://commons.wikimedia.org/wiki/File%3AAreca_hutchinsoniana.jpg) on Wikimedia Commons
- `xmp_test_2.jpg` - Uploaded by [Edward Steven](https://commons.wikimedia.org/wiki/File%3AAreca_hutchinsoniana.jpg) on Wikimedia Commons
## Acknowledgments
We gratefully acknowledge the contributions of Carlseibert and Edward Steven for making these images available under Creative Commons licenses. Their work enables us to test metadata extraction functionality with real-world EXIF and XMP data.
## Usage
These test images are used in PowerRename's unit tests to verify correct extraction of:
- EXIF metadata (camera make, model, ISO, aperture, shutter speed, etc.)
- XMP metadata (creator, title, description, copyright, etc.)
- GPS coordinates
- Date/time information
## License Compliance
These test images are distributed as part of the PowerToys source code repository under their original Creative Commons licenses:
- Files from Carlseibert: CC BY-SA 4.0
- Files from Edward Steven: CC BY-SA 2.0
**Modifications:** These images have not been modified from their original versions downloaded from Wikimedia Commons. They are used in their original form for metadata extraction testing purposes.
**Distribution:** These test images are included in the PowerToys source repository and comply with the terms of their respective Creative Commons licenses through proper attribution in this file. While included in the source code, these images are not distributed in end-user installation packages or releases.
**Derivatives:** Any modifications or derivative works of these images must comply with the respective CC BY-SA license terms, including proper attribution and applying the same license to the modified versions.
For more information about Creative Commons licenses, visit: https://creativecommons.org/licenses/

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB