Skip to content

Conversation

@meesoft
Copy link
Owner

@meesoft meesoft commented Jan 3, 2026

Summary by CodeRabbit

  • New Features

    • Added a "Reset rotation tag" action in the Metadata/EXIF context menu with confirmation.
  • Bug Fixes

    • Saved images now have EXIF rotation cleared so photos display correctly.
    • Thumbnail and metadata resets are routed through a central reset for more consistent updates.
    • Orientation state handling tightened to prevent unintended external modification.
  • Tests

    • Added tests validating rotation-reset behavior and improved test cancellation handling.

✏️ Tip: You can customize this high-level summary in your review settings.

@meesoft meesoft marked this pull request as ready for review January 3, 2026 19:34
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 3, 2026

Walkthrough

Adds a Rotate0 command and UI item to reset JPEG rotation, centralizes thumbnail/metadata reset via PictureItemViewModel.ResetThumbnailAndMetadata, makes JPEG-metadata parameters nullable, introduces ExifHandler.ResetOrientation to normalize EXIF orientation before saving, and updates tests and helpers accordingly.

Changes

Cohort / File(s) Summary
Exif / Metadata handler
PhotoLocator/Metadata/ExifHandler.cs
Added public static BitmapMetadata? ResetOrientation(BitmapMetadata? metadata) (annotated with NotNullIfNotNull) that clones frozen metadata and forces orientation tags to 1 when >1; small refactor in DecodeMetadataAsync.
JPEG transform & save flows
PhotoLocator/JpegTransformCommands.cs
Added public ICommand Rotate0Command; SaveProcessedImageAsync and BatchProcessLocalContrastAsync now accept BitmapMetadata?; all SaveToFile calls wrap metadata with ExifHandler.ResetOrientation(...); removed direct per-item orientation writes during rotate/crop.
ViewModel encapsulation & reset
PhotoLocator/PictureItemViewModel.cs
Made setters private for TimeStamp, ThumbnailImage, MetadataString, Orientation; added _thumbnailImage backing field; added internal void ResetThumbnailAndMetadata(); added a log line in LoadMetadataAsync.
Main view integration
PhotoLocator/MainViewModel.cs
Replaced direct thumbnail/metadata clearing with ResetThumbnailAndMetadata() when files change or items are added.
UI
PhotoLocator/MainWindow.xaml
Added context-menu item "Reset rotation tag" under Metadata / EXIF bound to JpegTransformCommands.Rotate0Command.
Tests & test helpers
PhotoLocatorTest/... (multiple)
Added TestContext properties and use its CancellationToken in tests; exposed VideoProcessingTest.SourceVideoPath as public const; added ExifHandler tests verifying ResetOrientation behavior and metadata preservation; minor test assertions and small fixes.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant User
  participant UI as MainWindow/MainViewModel
  participant Cmd as JpegTransformCommands
  participant VM as PictureItemViewModel
  participant Exif as ExifHandler
  participant FS as GeneralFileFormatHandler

  Note over User,UI: User selects "Reset rotation tag"
  User->>UI: Invoke Rotate0Command
  UI->>Cmd: Execute Rotate0Command(selectedItem)
  Cmd->>VM: ResetThumbnailAndMetadata()
  Cmd->>Exif: ResetOrientation(metadata?)
  Exif-->>Cmd: normalizedMetadata (or null)
  Cmd->>FS: SaveToFile(imageStream, normalizedMetadata)
  FS-->>Cmd: SaveResult
  Cmd->>VM: trigger metadata/thumbnail reload
  VM-->>UI: UI updates (cleared thumbnail / normalized EXIF)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Pre-merge checks and finishing touches

✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix orientation after edit' directly and concisely describes the main change: resetting JPEG rotation metadata after image editing operations.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch features/FixEditRotation

📜 Recent review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f6cc166 and de7c87d.

📒 Files selected for processing (1)
  • PhotoLocator/PictureItemViewModel.cs
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2024-12-07T15:05:08.244Z
Learnt from: meesoft
Repo: meesoft/PhotoLocator PR: 9
File: PhotoLocator/PictureFileFormats/JpegliEncoder.cs:10-10
Timestamp: 2024-12-07T15:05:08.244Z
Learning: In the `PhotoLocator.PictureFileFormats` namespace, classes like `JpegliEncoder` are intended for internal use only and should remain internal.

Applied to files:

  • PhotoLocator/PictureItemViewModel.cs
🧬 Code graph analysis (1)
PhotoLocator/PictureItemViewModel.cs (1)
PhotoLocator/Helpers/Log.cs (2)
  • Log (7-36)
  • Write (14-24)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Analyze (csharp)
🔇 Additional comments (1)
PhotoLocator/PictureItemViewModel.cs (1)

150-150: LGTM: Improved encapsulation

Making the setters private for TimeStamp, ThumbnailImage, MetadataString, and Orientation prevents unintended external mutations and follows proper encapsulation principles. The introduction of an explicit backing field for ThumbnailImage (line 159) is consistent with the existing pattern used for other properties.

Also applies to: 156-159, 171-171, 194-194


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 3, 2026

Note

Unit test generation is an Early Access feature. Expect some limitations and changes as we gather feedback and continue to improve it.


Generating unit tests... This may take up to 20 minutes.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 3, 2026

Here are the copyable unit test edits:

Copyable Edits

PhotoLocatorTest/JpegTransformCommandsTest.cs

This is a new file.

using Moq;
using PhotoLocator.Metadata;
using PhotoLocator.Settings;
using System.Windows.Input;

namespace PhotoLocator
{
    [TestClass]
    public class JpegTransformCommandsTest
    {
        private Mock<IMainViewModel> _mockMainViewModel = null!;
        private Mock<ISettings> _mockSettings = null!;
        private JpegTransformCommands _commands = null!;

        [TestInitialize]
        public void Setup()
        {
            _mockMainViewModel = new Mock<IMainViewModel>();
            _mockSettings = new Mock<ISettings>();
            _mockMainViewModel.Setup(m => m.Settings).Returns(_mockSettings.Object);
            _commands = new JpegTransformCommands(_mockMainViewModel.Object);
        }

        [TestMethod]
        public void Rotate0Command_ShouldBeAvailable()
        {
            var command = _commands.Rotate0Command;
            
            Assert.IsNotNull(command);
            Assert.IsInstanceOfType(command, typeof(ICommand));
        }

        [TestMethod]
        public void Rotate0Command_CanExecute_ShouldReturnTrue_WhenFileIsSelected()
        {
            var mockItem = new Mock<PictureItemViewModel>("test.jpg", false, null, null);
            _mockMainViewModel.Setup(m => m.SelectedItem).Returns(mockItem.Object);
            
            var command = _commands.Rotate0Command;
            
            Assert.IsTrue(command.CanExecute(null));
        }

        [TestMethod]
        public void Rotate0Command_CanExecute_ShouldReturnFalse_WhenNoFileIsSelected()
        {
            _mockMainViewModel.Setup(m => m.SelectedItem).Returns((PictureItemViewModel?)null);
            
            var command = _commands.Rotate0Command;
            
            Assert.IsFalse(command.CanExecute(null));
        }

        [TestMethod]
        public void Rotate0Command_CanExecute_ShouldReturnFalse_WhenDirectoryIsSelected()
        {
            var mockItem = new PictureItemViewModel("testDir", true, null, null);
            _mockMainViewModel.Setup(m => m.SelectedItem).Returns(mockItem);
            
            var command = _commands.Rotate0Command;
            
            Assert.IsFalse(command.CanExecute(null));
        }

        [TestMethod]
        public void RotateLeftCommand_ShouldBeAvailable()
        {
            var command = _commands.RotateLeftCommand;
            
            Assert.IsNotNull(command);
            Assert.IsInstanceOfType(command, typeof(ICommand));
        }

        [TestMethod]
        public void RotateRightCommand_ShouldBeAvailable()
        {
            var command = _commands.RotateRightCommand;
            
            Assert.IsNotNull(command);
            Assert.IsInstanceOfType(command, typeof(ICommand));
        }

        [TestMethod]
        public void Rotate180Command_ShouldBeAvailable()
        {
            var command = _commands.Rotate180Command;
            
            Assert.IsNotNull(command);
            Assert.IsInstanceOfType(command, typeof(ICommand));
        }

        [TestMethod]
        public void LocalContrastCommand_ShouldBeAvailable()
        {
            var command = _commands.LocalContrastCommand;
            
            Assert.IsNotNull(command);
            Assert.IsInstanceOfType(command, typeof(ICommand));
        }

        [TestMethod]
        public void LocalContrastCommand_CanExecute_ShouldReturnTrue_WhenFileIsSelected()
        {
            var mockItem = new Mock<PictureItemViewModel>("test.jpg", false, null, null);
            _mockMainViewModel.Setup(m => m.SelectedItem).Returns(mockItem.Object);
            
            var command = _commands.LocalContrastCommand;
            
            Assert.IsTrue(command.CanExecute(null));
        }

        [TestMethod]
        public void LocalContrastCommand_CanExecute_ShouldReturnFalse_WhenNoFileIsSelected()
        {
            _mockMainViewModel.Setup(m => m.SelectedItem).Returns((PictureItemViewModel?)null);
            
            var command = _commands.LocalContrastCommand;
            
            Assert.IsFalse(command.CanExecute(null));
        }

        [TestMethod]
        public void AllRotateCommands_ShouldRequireFileSelection()
        {
            _mockMainViewModel.Setup(m => m.SelectedItem).Returns((PictureItemViewModel?)null);
            
            Assert.IsFalse(_commands.RotateLeftCommand.CanExecute(null));
            Assert.IsFalse(_commands.RotateRightCommand.CanExecute(null));
            Assert.IsFalse(_commands.Rotate180Command.CanExecute(null));
            Assert.IsFalse(_commands.Rotate0Command.CanExecute(null));
        }

        [TestMethod]
        public void Constructor_ShouldThrowArgumentNullException_WhenMainViewModelIsNull()
        {
            Assert.ThrowsException<ArgumentNullException>(() => new JpegTransformCommands(null!));
        }

        [TestMethod]
        public void Commands_ShouldBeConsistent_BetweenMultipleCalls()
        {
            var command1 = _commands.Rotate0Command;
            var command2 = _commands.Rotate0Command;
            
            Assert.AreSame(command1, command2);
        }
    }
}

PhotoLocatorTest/JpegTransformIntegrationTest.cs

This is a new file.

using PhotoLocator.Metadata;
using PhotoLocator.PictureFileFormats;
using System.Windows.Media;
using System.Windows.Media.Imaging;

namespace PhotoLocator
{
    [TestClass]
    public class JpegTransformIntegrationTest
    {
        [TestMethod]
        public void ResetOrientation_ShouldBeApplied_WhenSavingProcessedImage()
        {
            const string TestFileName = "orientation_reset_test.jpg";
            
            using var sourceStream = GetType().Assembly.GetManifestResourceStream(@"PhotoLocator.TestData.2022-06-17_19.03.02.jpg")
                ?? throw new FileNotFoundException("Resource not found");
            var metadata = ExifHandler.LoadMetadata(sourceStream) ?? throw new Exception("Unable to load metadata");
            
            // Set orientation to simulate rotated image
            metadata = metadata.Clone();
            metadata.SetQuery(ExifHandler.OrientationQuery1, (ushort)6);
            
            // Create a test bitmap
            var bitmap = BitmapSource.Create(100, 100, 96, 96, PixelFormats.Bgr24, null, new byte[100 * 100 * 3], 100 * 3);
            
            // Apply ResetOrientation and save
            var resetMetadata = ExifHandler.ResetOrientation(metadata);
            GeneralFileFormatHandler.SaveToFile(bitmap, TestFileName, resetMetadata, 90);
            
            // Verify orientation was reset
            using var targetStream = File.OpenRead(TestFileName);
            var savedMetadata = ExifHandler.LoadMetadata(targetStream)!;
            var orientationValue = savedMetadata.GetQuery(ExifHandler.OrientationQuery1) as ushort?;
            
            Assert.AreEqual((ushort)1, orientationValue);
            
            File.Delete(TestFileName);
        }

        [TestMethod]
        public void ResetOrientation_ShouldPreserveOtherMetadata()
        {
            const string TestFileName = "metadata_preservation_test.jpg";
            
            using var sourceStream = GetType().Assembly.GetManifestResourceStream(@"PhotoLocator.TestData.2022-06-17_19.03.02.jpg")
                ?? throw new FileNotFoundException("Resource not found");
            var metadata = ExifHandler.LoadMetadata(sourceStream) ?? throw new Exception("Unable to load metadata");
            
            var originalCameraModel = metadata.CameraModel;
            var originalGeotag = ExifHandler.GetGeotag(metadata);
            
            // Set orientation and reset it
            metadata = metadata.Clone();
            metadata.SetQuery(ExifHandler.OrientationQuery1, (ushort)3);
            var resetMetadata = ExifHandler.ResetOrientation(metadata);
            
            // Create and save bitmap
            var bitmap = BitmapSource.Create(100, 100, 96, 96, PixelFormats.Bgr24, null, new byte[100 * 100 * 3], 100 * 3);
            GeneralFileFormatHandler.SaveToFile(bitmap, TestFileName, resetMetadata, 90);
            
            // Verify other metadata is preserved
            using var targetStream = File.OpenRead(TestFileName);
            var savedMetadata = ExifHandler.LoadMetadata(targetStream)!;
            
            Assert.AreEqual(originalCameraModel, savedMetadata.CameraModel);
            var savedGeotag = ExifHandler.GetGeotag(savedMetadata);
            Assert.IsNotNull(savedGeotag);
            Assert.AreEqual(originalGeotag?.Latitude, savedGeotag.Latitude, 0.001);
            Assert.AreEqual(originalGeotag?.Longitude, savedGeotag.Longitude, 0.001);
            
            File.Delete(TestFileName);
        }

        [TestMethod]
        public void ResetOrientation_WithNullMetadata_ShouldHandleGracefully()
        {
            const string TestFileName = "null_metadata_test.jpg";
            
            var bitmap = BitmapSource.Create(100, 100, 96, 96, PixelFormats.Bgr24, null, new byte[100 * 100 * 3], 100 * 3);
            
            // This should not throw
            var resetMetadata = ExifHandler.ResetOrientation(null);
            GeneralFileFormatHandler.SaveToFile(bitmap, TestFileName, resetMetadata, 90);
            
            Assert.IsTrue(File.Exists(TestFileName));
            
            File.Delete(TestFileName);
        }

        [TestMethod]
        public void PictureItemViewModel_ResetThumbnailAndMetadata_ShouldResetOrientation()
        {
            File.Create("reset_test.jpg").Dispose();
            
            try
            {
                var item = new PictureItemViewModel(Path.Combine(Directory.GetCurrentDirectory(), "reset_test.jpg"), false, null, null);
                
                // Set orientation through reflection (since it's private set)
                var orientationField = typeof(PictureItemViewModel).GetField("<Orientation>k__BackingField",
                    System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
                orientationField?.SetValue(item, System.Windows.Controls.Rotation.Rotate90);
                
                // Call reset
                item.ResetThumbnailAndMetadata();
                
                // Verify orientation is reset
                Assert.AreEqual(System.Windows.Controls.Rotation.Rotate0, item.Orientation);
            }
            finally
            {
                File.Delete("reset_test.jpg");
            }
        }

        [TestMethod]
        public void BatchProcessing_ShouldResetOrientation_ForAllImages()
        {
            var testFiles = new[] { "batch1.jpg", "batch2.jpg", "batch3.jpg" };
            
            try
            {
                foreach (var file in testFiles)
                {
                    using var sourceStream = GetType().Assembly.GetManifestResourceStream(@"PhotoLocator.TestData.2022-06-17_19.03.02.jpg")
                        ?? throw new FileNotFoundException("Resource not found");
                    var metadata = ExifHandler.LoadMetadata(sourceStream) ?? throw new Exception("Unable to load metadata");
                    
                    metadata = metadata.Clone();
                    metadata.SetQuery(ExifHandler.OrientationQuery1, (ushort)6);
                    
                    var bitmap = BitmapSource.Create(50, 50, 96, 96, PixelFormats.Bgr24, null, new byte[50 * 50 * 3], 50 * 3);
                    var resetMetadata = ExifHandler.ResetOrientation(metadata);
                    GeneralFileFormatHandler.SaveToFile(bitmap, file, resetMetadata, 90);
                }
                
                // Verify all files have orientation reset
                foreach (var file in testFiles)
                {
                    using var stream = File.OpenRead(file);
                    var savedMetadata = ExifHandler.LoadMetadata(stream)!;
                    var orientationValue = savedMetadata.GetQuery(ExifHandler.OrientationQuery1) as ushort?;
                    Assert.AreEqual((ushort)1, orientationValue, $"Orientation not reset in {file}");
                }
            }
            finally
            {
                foreach (var file in testFiles)
                {
                    if (File.Exists(file))
                        File.Delete(file);
                }
            }
        }
    }
}

PhotoLocatorTest/Metadata/ExifHandlerTest.cs

@@ -262,3 +262,107 @@
         }
     }
 }
+
+        [TestMethod]
+        public void ResetOrientation_ShouldResetOrientationToOne_WhenOrientationIsSet()
+        {
+            using var stream = GetType().Assembly.GetManifestResourceStream(@"PhotoLocator.TestData.2022-06-17_19.03.02.jpg")
+                ?? throw new FileNotFoundException("Resource not found");
+            var metadata = ExifHandler.LoadMetadata(stream) ?? throw new Exception("Unable to load metadata");
+            
+            // Set orientation to a non-default value
+            metadata = metadata.Clone();
+            metadata.SetQuery(ExifHandler.OrientationQuery1, (ushort)6);
+            
+            var result = ExifHandler.ResetOrientation(metadata);
+            
+            Assert.IsNotNull(result);
+            var orientationValue = result.GetQuery(ExifHandler.OrientationQuery1) as ushort?;
+            Assert.AreEqual((ushort)1, orientationValue);
+        }
+
+        [TestMethod]
+        public void ResetOrientation_ShouldCloneMetadata_WhenFrozen()
+        {
+            using var stream = GetType().Assembly.GetManifestResourceStream(@"PhotoLocator.TestData.2022-06-17_19.03.02.jpg")
+                ?? throw new FileNotFoundException("Resource not found");
+            var metadata = ExifHandler.LoadMetadata(stream) ?? throw new Exception("Unable to load metadata");
+            
+            metadata = metadata.Clone();
+            metadata.SetQuery(ExifHandler.OrientationQuery1, (ushort)3);
+            metadata.Freeze();
+            
+            var result = ExifHandler.ResetOrientation(metadata);
+            
+            Assert.IsNotNull(result);
+            Assert.IsFalse(ReferenceEquals(metadata, result));
+            var orientationValue = result.GetQuery(ExifHandler.OrientationQuery1) as ushort?;
+            Assert.AreEqual((ushort)1, orientationValue);
+        }
+
+        [TestMethod]
+        public void ResetOrientation_ShouldReturnNull_WhenMetadataIsNull()
+        {
+            var result = ExifHandler.ResetOrientation(null);
+            
+            Assert.IsNull(result);
+        }
+
+        [TestMethod]
+        public void ResetOrientation_ShouldNotModify_WhenOrientationIsAlreadyOne()
+        {
+            using var stream = GetType().Assembly.GetManifestResourceStream(@"PhotoLocator.TestData.2022-06-17_19.03.02.jpg")
+                ?? throw new FileNotFoundException("Resource not found");
+            var metadata = ExifHandler.LoadMetadata(stream) ?? throw new Exception("Unable to load metadata");
+            
+            metadata = metadata.Clone();
+            metadata.SetQuery(ExifHandler.OrientationQuery1, (ushort)1);
+            
+            var result = ExifHandler.ResetOrientation(metadata);
+            
+            Assert.IsNotNull(result);
+            var orientationValue = result.GetQuery(ExifHandler.OrientationQuery1) as ushort?;
+            Assert.AreEqual((ushort)1, orientationValue);
+        }
+
+        [TestMethod]
+        public void ResetOrientation_ShouldResetBothOrientationQueries()
+        {
+            using var stream = GetType().Assembly.GetManifestResourceStream(@"PhotoLocator.TestData.2022-06-17_19.03.02.jpg")
+                ?? throw new FileNotFoundException("Resource not found");
+            var metadata = ExifHandler.LoadMetadata(stream) ?? throw new Exception("Unable to load metadata");
+            
+            metadata = metadata.Clone();
+            metadata.SetQuery(ExifHandler.OrientationQuery1, (ushort)6);
+            metadata.SetQuery(ExifHandler.OrientationQuery2, (ushort)8);
+            
+            var result = ExifHandler.ResetOrientation(metadata);
+            
+            Assert.IsNotNull(result);
+            var orientation1 = result.GetQuery(ExifHandler.OrientationQuery1) as ushort?;
+            var orientation2 = result.GetQuery(ExifHandler.OrientationQuery2) as ushort?;
+            Assert.AreEqual((ushort)1, orientation1);
+            Assert.AreEqual((ushort)1, orientation2);
+        }
+
+        [TestMethod]
+        public void ResetOrientation_ShouldHandleAllRotationValues()
+        {
+            ushort[] rotationValues = { 3, 6, 8 };
+            
+            foreach (var rotationValue in rotationValues)
+            {
+                using var stream = GetType().Assembly.GetManifestResourceStream(@"PhotoLocator.TestData.2022-06-17_19.03.02.jpg")
+                    ?? throw new FileNotFoundException("Resource not found");
+                var metadata = ExifHandler.LoadMetadata(stream) ?? throw new Exception("Unable to load metadata");
+                
+                metadata = metadata.Clone();
+                metadata.SetQuery(ExifHandler.OrientationQuery1, rotationValue);
+                
+                var result = ExifHandler.ResetOrientation(metadata);
+                
+                Assert.IsNotNull(result);
+                var orientationValue = result.GetQuery(ExifHandler.OrientationQuery1) as ushort?;
+                Assert.AreEqual((ushort)1, orientationValue, $"Failed for rotation value {rotationValue}");
+            }
+        }

PhotoLocatorTest/PictureItemViewModelTest.cs

@@ -22,3 +22,92 @@
         Assert.IsTrue(File.Exists(@"sidecar\rename2.jpg.cop"));
     }
 }
+
+    [TestMethod]
+    public void ResetThumbnailAndMetadata_ShouldResetAllProperties()
+    {
+        var file = new PictureItemViewModel("test.jpg", false, null, null);
+        
+        // Set up some initial state (using reflection since properties are private set)
+        var thumbnailProperty = typeof(PictureItemViewModel).GetProperty("ThumbnailImage");
+        var metadataProperty = typeof(PictureItemViewModel).GetProperty("MetadataString");
+        var orientationProperty = typeof(PictureItemViewModel).GetProperty("Orientation");
+        
+        // Create a dummy image source
+        var dummyImage = System.Windows.Media.Imaging.BitmapSource.Create(
+            1, 1, 96, 96, System.Windows.Media.PixelFormats.Bgr24, null, new byte[3], 3);
+        thumbnailProperty?.SetValue(file, dummyImage);
+        
+        // Set metadata string through field since it's privately set
+        var metadataField = typeof(PictureItemViewModel).GetField("_metadataString", 
+            System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+        metadataField?.SetValue(file, "Test metadata");
+        
+        // Set orientation through property
+        var orientationField = typeof(PictureItemViewModel).GetField("<Orientation>k__BackingField",
+            System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+        orientationField?.SetValue(file, System.Windows.Controls.Rotation.Rotate90);
+        
+        file.ResetThumbnailAndMetadata();
+        
+        Assert.IsNull(file.ThumbnailImage);
+        Assert.IsNull(file.MetadataString);
+        Assert.AreEqual(System.Windows.Controls.Rotation.Rotate0, file.Orientation);
+    }
+
+    [TestMethod]
+    public void ResetThumbnailAndMetadata_ShouldBeIdempotent()
+    {
+        var file = new PictureItemViewModel("test.jpg", false, null, null);
+        
+        file.ResetThumbnailAndMetadata();
+        file.ResetThumbnailAndMetadata();
+        
+        Assert.IsNull(file.ThumbnailImage);
+        Assert.IsNull(file.MetadataString);
+        Assert.AreEqual(System.Windows.Controls.Rotation.Rotate0, file.Orientation);
+    }
+
+    [TestMethod]
+    public void Orientation_ShouldBePrivateSet()
+    {
+        var file = new PictureItemViewModel("test.jpg", false, null, null);
+        var orientationProperty = typeof(PictureItemViewModel).GetProperty("Orientation");
+        
+        Assert.IsNotNull(orientationProperty);
+        Assert.IsTrue(orientationProperty.CanRead);
+        Assert.IsFalse(orientationProperty.CanWrite || orientationProperty.SetMethod?.IsPublic == true);
+    }
+
+    [TestMethod]
+    public void ThumbnailImage_ShouldBePrivateSet()
+    {
+        var file = new PictureItemViewModel("test.jpg", false, null, null);
+        var property = typeof(PictureItemViewModel).GetProperty("ThumbnailImage");
+        
+        Assert.IsNotNull(property);
+        Assert.IsTrue(property.CanRead);
+        Assert.IsFalse(property.SetMethod?.IsPublic == true);
+    }
+
+    [TestMethod]
+    public void MetadataString_ShouldBePrivateSet()
+    {
+        var file = new PictureItemViewModel("test.jpg", false, null, null);
+        var property = typeof(PictureItemViewModel).GetProperty("MetadataString");
+        
+        Assert.IsNotNull(property);
+        Assert.IsTrue(property.CanRead);
+        Assert.IsFalse(property.SetMethod?.IsPublic == true);
+    }
+
+    [TestMethod]
+    public void TimeStamp_ShouldBePrivateSet()
+    {
+        var file = new PictureItemViewModel("test.jpg", false, null, null);
+        var property = typeof(PictureItemViewModel).GetProperty("TimeStamp");
+        
+        Assert.IsNotNull(property);
+        Assert.IsTrue(property.CanRead);
+        Assert.IsFalse(property.SetMethod?.IsPublic == true);
+    }

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (4)
PhotoLocatorTest/PictureFileFormats/JpegliEncoderTest.cs (1)

11-12: LGTM! Standard MSTest pattern for test context injection.

The TestContext property follows the standard MSTest convention and will be initialized by the test framework before any test methods execute.

If nullable reference types are enabled in the project, consider adding a nullable annotation or null-forgiving operator to avoid potential CS8618 warnings:

Optional: Nullability annotation patterns

Option 1 (nullable):

-public TestContext TestContext { get; set; }
+public TestContext? TestContext { get; set; }

Option 2 (null-forgiving operator):

-public TestContext TestContext { get; set; }
+public TestContext TestContext { get; set; } = null!;
PhotoLocatorTest/BitmapOperations/TimeSliceOperationTest.cs (1)

11-96: Consider clarifying the relationship to PR objectives.

These test improvements (cancellation support, assertions, code reuse) are valuable but don't appear directly related to the PR title "Fix orientation after edit." If these are supporting changes for the orientation fix or general test infrastructure improvements, consider mentioning that in the PR description for clarity.

PhotoLocatorTest/Metadata/ExifHandlerTest.cs (2)

264-290: Test logic looks good; consider adding file cleanup.

The test correctly verifies that ResetOrientation resets the orientation tag to 1 when saving a processed image. However, the test file is not deleted after execution, which could lead to test pollution or issues in CI/CD environments.

🔎 Suggested cleanup pattern
 [TestMethod]
 public void ResetOrientation_ShouldBeApplied_WhenSavingProcessedImage()
 {
     const string TestFileName = "orientation_reset_test.jpg";
+    try
+    {
+        using var sourceStream = GetType().Assembly.GetManifestResourceStream(@"PhotoLocator.TestData.2022-06-17_19.03.02.jpg")
+            ?? throw new FileNotFoundException("Resource not found");
+        var metadata = ExifHandler.LoadMetadata(sourceStream) ?? throw new Exception("Unable to load metadata");
 
-    using var sourceStream = GetType().Assembly.GetManifestResourceStream(@"PhotoLocator.TestData.2022-06-17_19.03.02.jpg")
-        ?? throw new FileNotFoundException("Resource not found");
-    var metadata = ExifHandler.LoadMetadata(sourceStream) ?? throw new Exception("Unable to load metadata");
-
-    // Set orientation to simulate rotated image
-    metadata = metadata.Clone();
-    metadata.SetQuery(ExifHandler.OrientationQuery1, (ushort)6);
-
-    // Create a test bitmap
-    var bitmap = BitmapSource.Create(10, 10, 96, 96, PixelFormats.Bgr24, null, new byte[10 * 10 * 3], 10 * 3);
-
-    // Apply ResetOrientation and save
-    var resetMetadata = ExifHandler.ResetOrientation(metadata);
-    GeneralFileFormatHandler.SaveToFile(bitmap, TestFileName, resetMetadata, 90);
-
-    // Verify orientation was reset
-    using var targetStream = File.OpenRead(TestFileName);
-    var savedMetadata = ExifHandler.LoadMetadata(targetStream)!;
-    var orientationValue = savedMetadata.GetQuery(ExifHandler.OrientationQuery1) as ushort?;
-
-    Assert.AreEqual((ushort)1, orientationValue);
+        // Set orientation to simulate rotated image
+        metadata = metadata.Clone();
+        metadata.SetQuery(ExifHandler.OrientationQuery1, (ushort)6);
+
+        // Create a test bitmap
+        var bitmap = BitmapSource.Create(10, 10, 96, 96, PixelFormats.Bgr24, null, new byte[10 * 10 * 3], 10 * 3);
+
+        // Apply ResetOrientation and save
+        var resetMetadata = ExifHandler.ResetOrientation(metadata);
+        GeneralFileFormatHandler.SaveToFile(bitmap, TestFileName, resetMetadata, 90);
+
+        // Verify orientation was reset
+        using var targetStream = File.OpenRead(TestFileName);
+        var savedMetadata = ExifHandler.LoadMetadata(targetStream)!;
+        var orientationValue = savedMetadata.GetQuery(ExifHandler.OrientationQuery1) as ushort?;
+
+        Assert.AreEqual((ushort)1, orientationValue);
+    }
+    finally
+    {
+        if (File.Exists(TestFileName))
+            File.Delete(TestFileName);
+    }
 }

292-323: Test logic looks good; consider adding file cleanup.

The test correctly verifies that ResetOrientation preserves other metadata (camera model, geotag) when resetting orientation. However, the test file is not deleted after execution.

🔎 Suggested cleanup pattern
 [TestMethod]
 public void ResetOrientation_ShouldPreserveOtherMetadata()
 {
     const string TestFileName = "metadata_preservation_test.jpg";
+    try
+    {
+        using var sourceStream = GetType().Assembly.GetManifestResourceStream(@"PhotoLocator.TestData.2022-06-17_19.03.02.jpg")
+            ?? throw new FileNotFoundException("Resource not found");
+        var metadata = ExifHandler.LoadMetadata(sourceStream) ?? throw new Exception("Unable to load metadata");
 
-    using var sourceStream = GetType().Assembly.GetManifestResourceStream(@"PhotoLocator.TestData.2022-06-17_19.03.02.jpg")
-        ?? throw new FileNotFoundException("Resource not found");
-    var metadata = ExifHandler.LoadMetadata(sourceStream) ?? throw new Exception("Unable to load metadata");
-
-    var originalCameraModel = metadata.CameraModel;
-    var originalGeotag = ExifHandler.GetGeotag(metadata);
-    Assert.IsNotNull(originalGeotag);
-
-    // Set orientation and reset it
-    metadata = metadata.Clone();
-    metadata.SetQuery(ExifHandler.OrientationQuery1, (ushort)3);
-    var resetMetadata = ExifHandler.ResetOrientation(metadata);
-
-    // Create and save bitmap
-    var bitmap = BitmapSource.Create(10, 10, 96, 96, PixelFormats.Bgr24, null, new byte[10 * 10 * 3], 10 * 3);
-    GeneralFileFormatHandler.SaveToFile(bitmap, TestFileName, resetMetadata, 90);
-
-    // Verify other metadata is preserved
-    using var targetStream = File.OpenRead(TestFileName);
-    var savedMetadata = ExifHandler.LoadMetadata(targetStream)!;
-
-    Assert.AreEqual(originalCameraModel, savedMetadata.CameraModel);
-    var savedGeotag = ExifHandler.GetGeotag(savedMetadata);
-    Assert.IsNotNull(savedGeotag);
-    Assert.AreEqual(originalGeotag.Latitude, savedGeotag.Latitude, 0.001);
-    Assert.AreEqual(originalGeotag.Longitude, savedGeotag.Longitude, 0.001);
+        var originalCameraModel = metadata.CameraModel;
+        var originalGeotag = ExifHandler.GetGeotag(metadata);
+        Assert.IsNotNull(originalGeotag);
+
+        // Set orientation and reset it
+        metadata = metadata.Clone();
+        metadata.SetQuery(ExifHandler.OrientationQuery1, (ushort)3);
+        var resetMetadata = ExifHandler.ResetOrientation(metadata);
+
+        // Create and save bitmap
+        var bitmap = BitmapSource.Create(10, 10, 96, 96, PixelFormats.Bgr24, null, new byte[10 * 10 * 3], 10 * 3);
+        GeneralFileFormatHandler.SaveToFile(bitmap, TestFileName, resetMetadata, 90);
+
+        // Verify other metadata is preserved
+        using var targetStream = File.OpenRead(TestFileName);
+        var savedMetadata = ExifHandler.LoadMetadata(targetStream)!;
+
+        Assert.AreEqual(originalCameraModel, savedMetadata.CameraModel);
+        var savedGeotag = ExifHandler.GetGeotag(savedMetadata);
+        Assert.IsNotNull(savedGeotag);
+        Assert.AreEqual(originalGeotag.Latitude, savedGeotag.Latitude, 0.001);
+        Assert.AreEqual(originalGeotag.Longitude, savedGeotag.Longitude, 0.001);
+    }
+    finally
+    {
+        if (File.Exists(TestFileName))
+            File.Delete(TestFileName);
+    }
 }
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fa3b775 and 39f60e8.

📒 Files selected for processing (5)
  • PhotoLocatorTest/BitmapOperations/TimeSliceOperationTest.cs
  • PhotoLocatorTest/Helpers/VideoProcessingTest.cs
  • PhotoLocatorTest/Metadata/ExifHandlerTest.cs
  • PhotoLocatorTest/PictureFileFormats/JpegliEncoderTest.cs
  • PhotoLocatorTest/PictureFileFormats/PhotoshopFileFormatHandlerTest.cs
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2024-12-07T15:05:08.244Z
Learnt from: meesoft
Repo: meesoft/PhotoLocator PR: 9
File: PhotoLocator/PictureFileFormats/JpegliEncoder.cs:10-10
Timestamp: 2024-12-07T15:05:08.244Z
Learning: In the `PhotoLocator.PictureFileFormats` namespace, classes like `JpegliEncoder` are intended for internal use only and should remain internal.

Applied to files:

  • PhotoLocatorTest/PictureFileFormats/JpegliEncoderTest.cs
🧬 Code graph analysis (1)
PhotoLocatorTest/PictureFileFormats/JpegliEncoderTest.cs (1)
PhotoLocator/PictureFileFormats/GeneralFileFormatHandler.cs (1)
  • GeneralFileFormatHandler (9-78)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: build
  • GitHub Check: Analyze (csharp)
🔇 Additional comments (8)
PhotoLocatorTest/PictureFileFormats/PhotoshopFileFormatHandlerTest.cs (1)

16-16: LGTM! Code quality improvement for discard pattern.

The change to explicitly assign the FloatBitmap constructor result to the discard variable _ is correct and follows C# best practices. This likely addresses a static analyzer warning about unused values while maintaining the test's intent to verify that both LoadFromStream and the FloatBitmap constructor succeed without throwing exceptions.

Note: This change appears orthogonal to the PR's main objective of fixing orientation after edit—it's a code quality improvement.

Also applies to: 24-24, 32-32, 40-40

PhotoLocatorTest/PictureFileFormats/JpegliEncoderTest.cs (1)

26-26: Excellent improvement for test cancellation support.

Passing TestContext.CancellationToken allows the test framework to properly cancel long-running operations, improving test reliability and respecting timeout configurations.

PhotoLocatorTest/Helpers/VideoProcessingTest.cs (1)

11-11: LGTM! Good refactoring to enable code reuse.

Making SourceVideoPath public allows other test classes to reference it, eliminating duplication. This is consumed by TimeSliceOperationTest in the codebase.

PhotoLocatorTest/BitmapOperations/TimeSliceOperationTest.cs (5)

11-11: LGTM! Standard MSTest pattern.

Adding the TestContext property is the standard MSTest approach for accessing test execution context, including the cancellation token used throughout the test methods.


17-18: Verify the duplicate image paths are intentional.

Both array entries reference the same test image. If this is intentional for testing time-slice behavior with identical frames, consider adding a comment to clarify. Otherwise, ensure these should be distinct test images.


32-32: LGTM! Excellent improvement for test cancellation support.

Passing TestContext.CancellationToken to all async operations enables MSTest to cancel long-running tests, improving test reliability and developer experience.

Also applies to: 34-34, 59-59, 86-86, 91-91


38-39: LGTM! Good additions to verify frame processing metrics.

The new assertions confirm that the expected number of frames were used and none were skipped, strengthening the test validation.

Also applies to: 68-69, 95-96


58-58: LGTM! Good refactoring to eliminate duplication.

Referencing VideoProcessingTest.SourceVideoPath centralizes the test video path definition, making it easier to maintain.

Also applies to: 85-85

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
PhotoLocator/PictureItemViewModel.cs (1)

238-238: Consider reducing log verbosity.

This log message will fire for every file during metadata loading, which could be verbose when processing many files or scrolling through a large directory. Consider logging only on errors or using a debug-level log that can be filtered.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 37cc60d and f6cc166.

📒 Files selected for processing (2)
  • PhotoLocator/JpegTransformCommands.cs
  • PhotoLocator/PictureItemViewModel.cs
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2024-12-07T15:05:08.244Z
Learnt from: meesoft
Repo: meesoft/PhotoLocator PR: 9
File: PhotoLocator/PictureFileFormats/JpegliEncoder.cs:10-10
Timestamp: 2024-12-07T15:05:08.244Z
Learning: In the `PhotoLocator.PictureFileFormats` namespace, classes like `JpegliEncoder` are intended for internal use only and should remain internal.

Applied to files:

  • PhotoLocator/PictureItemViewModel.cs
🧬 Code graph analysis (1)
PhotoLocator/PictureItemViewModel.cs (3)
PhotoLocator/RenameWindow.xaml.cs (1)
  • SetProperty (76-83)
PhotoLocator/MainViewModel.cs (1)
  • SetProperty (57-64)
PhotoLocator/Helpers/Log.cs (2)
  • Log (7-36)
  • Write (14-24)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Analyze (csharp)
🔇 Additional comments (5)
PhotoLocator/PictureItemViewModel.cs (1)

150-150: LGTM! Improved encapsulation.

Making these property setters private is a good practice, ensuring these properties are only modified internally by the class.

Also applies to: 157-157, 170-170, 193-193

PhotoLocator/JpegTransformCommands.cs (4)

157-157: LGTM! Nullable metadata parameters are appropriate.

Making the metadata parameters nullable is a good change since metadata may not always be available. The subsequent calls to ExifHandler.ResetOrientation handle null correctly according to the PR summary.

Also applies to: 177-177


170-171: LGTM! Consistent orientation reset before saving.

The addition of ExifHandler.ResetOrientation calls before all GeneralFileFormatHandler.SaveToFile operations ensures that EXIF orientation is consistently normalized across both single-file and batch processing paths. This aligns well with the PR objective.

Also applies to: 187-188, 194-195


32-37: Verify that JpegTransformations.Rotate with angle=0 resets EXIF orientation as intended.

The Rotate0Command calls RotateSelectedAsync(0), which delegates to JpegTransformations.Rotate(item.FullPath, item.GetProcessedFileName(), 0). The Rotate method simply converts the angle to a string and passes it to an external JpegTransform.exe executable. The UI message promises to "reset any rotation EXIF data," but the C# code shows no special handling for angle=0. The actual behavior depends entirely on what the external executable does with a "0" argument—whether it truly resets the EXIF orientation tag to 1, or if it's a no-op that leaves the tag unchanged.


39-55: Verify thumbnail/metadata reset after rotation operations.

After RotateSelectedAsync completes, the rotated files are detected by the file system watcher, which triggers the file change handler in MainViewModel (lines 1218-1228). This handler calls ResetThumbnailAndMetadata() for items whose thumbnails have been loaded, ensuring the UI reflects the new orientation.

However, note that unlike CropSelectedAsync, RotateSelectedAsync does not pause the file watcher during operation. Verify that this approach doesn't cause race conditions or unexpected behavior when the watcher detects file changes while rotation is in progress.

Comment on lines 347 to 352
internal void ResetThumbnailAndMetadata()
{
ThumbnailImage = null;
_metadataString = null;
Orientation = Rotation.Rotate0;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix PropertyChanged notification bypass.

Line 350 sets _metadataString = null directly, bypassing the SetProperty mechanism. This means PropertyChanged won't be raised for MetadataString, potentially breaking UI bindings. Lines 349 and 351 correctly use property setters.

🔎 Proposed fix
 internal void ResetThumbnailAndMetadata()
 {
     ThumbnailImage = null;
-    _metadataString = null;
+    MetadataString = null;
     Orientation = Rotation.Rotate0;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
internal void ResetThumbnailAndMetadata()
{
ThumbnailImage = null;
_metadataString = null;
Orientation = Rotation.Rotate0;
}
internal void ResetThumbnailAndMetadata()
{
ThumbnailImage = null;
MetadataString = null;
Orientation = Rotation.Rotate0;
}
🤖 Prompt for AI Agents
In PhotoLocator/PictureItemViewModel.cs around lines 347 to 352, the
ResetThumbnailAndMetadata method assigns _metadataString = null directly which
bypasses the SetProperty/PropertyChanged mechanism; change that direct field
assignment to use the MetadataString property (e.g., MetadataString = null) so
the SetProperty path runs and raises PropertyChanged for UI bindings, keeping
ThumbnailImage and Orientation assignments as-is.

@meesoft meesoft merged commit b0ad9f0 into main Jan 7, 2026
5 checks passed
@meesoft meesoft deleted the features/FixEditRotation branch January 7, 2026 21:23
@coderabbitai coderabbitai bot mentioned this pull request Jan 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants