Newer
Older
TIASShot / TIASshot.Tests / BitmapUtilTests.cs
using System;
using System.Drawing;
using System.Drawing.Imaging;
using NUnit.Framework;

namespace TIASshot.Tests {
    /// <summary>
    /// BitmapUtil.FastClone のテスト(ピュアロジック層)。
    ///
    /// GUIDE_08: FastClone は Bitmap の画素バッファを LockBits + memcpy で複製する
    /// ハードウェア非依存の処理であり,NUnit で仕様どおりの入出力を検証できる。
    ///
    /// 対象外について:
    /// 本コミット(609baf6・252cde3)の本質である Form1.ShowImage / PreviewMonitor の
    /// タイマー駆動描画・_dispBufLock を介したクロススレッド Dispose の所有権モデル・
    /// Lucam コールバックスレッドと UI スレッドの GDI 競合・WM_PAINT/WM_TIMER の
    /// スケジューリングは,実メッセージループ・実カメラ・副ディスプレイに依存する
    /// ハードウェア結合層(GUIDE_08)であり,NUnit の自動ユニットテストの対象外とする。
    /// これらは実機の MEMMONITOR 計測(TEST_01 参照)で反復検証済み。
    /// フェイクの UI ループを組んでまで検証する脆いテストは作らない。
    /// </summary>
    [TestFixture]
    public class BitmapUtilTests {

        // ---------------------------------------------------------------
        // 仕様1・2: サイズ・PixelFormat の一致/全画素一致
        // ---------------------------------------------------------------

        /// <summary>
        /// FastClone_24bppRgb_サイズとPixelFormatが入力と一致する
        /// </summary>
        [Test]
        public void FastClone_24bppRgb_サイズとPixelFormatが入力と一致する() {
            using (var src = CreateSolidBitmap(4, 3, PixelFormat.Format24bppRgb, Color.FromArgb(10, 20, 30)))
            using (var dst = BitmapUtil.FastClone(src)) {
                Assert.AreEqual(src.Width, dst.Width, "幅が一致すること");
                Assert.AreEqual(src.Height, dst.Height, "高さが一致すること");
                Assert.AreEqual(src.PixelFormat, dst.PixelFormat, "PixelFormat が一致すること");
            }
        }

        /// <summary>
        /// FastClone_24bppRgb_全画素が入力と一致する
        /// </summary>
        [Test]
        public void FastClone_24bppRgb_全画素が入力と一致する() {
            using (var src = CreateGradientBitmap(8, 5, PixelFormat.Format24bppRgb))
            using (var dst = BitmapUtil.FastClone(src)) {
                AssertPixelsEqual(src, dst);
            }
        }

        /// <summary>
        /// FastClone_32bppArgb_サイズとPixelFormatが入力と一致する
        /// </summary>
        [Test]
        public void FastClone_32bppArgb_サイズとPixelFormatが入力と一致する() {
            using (var src = CreateSolidBitmap(5, 4, PixelFormat.Format32bppArgb, Color.FromArgb(200, 10, 20, 30)))
            using (var dst = BitmapUtil.FastClone(src)) {
                Assert.AreEqual(src.Width, dst.Width, "幅が一致すること");
                Assert.AreEqual(src.Height, dst.Height, "高さが一致すること");
                Assert.AreEqual(src.PixelFormat, dst.PixelFormat, "PixelFormat が一致すること");
            }
        }

        /// <summary>
        /// FastClone_32bppArgb_全画素(アルファ含む)が入力と一致する
        /// </summary>
        [Test]
        public void FastClone_32bppArgb_全画素が入力と一致する() {
            using (var src = CreateGradientBitmap(6, 6, PixelFormat.Format32bppArgb))
            using (var dst = BitmapUtil.FastClone(src)) {
                AssertPixelsEqual(src, dst);
            }
        }

        // ---------------------------------------------------------------
        // 仕様3: 入力とは独立した画素バッファを持つ
        // ---------------------------------------------------------------

        /// <summary>
        /// FastClone_複製後に入力をDisposeしても複製は有効なまま読める
        /// </summary>
        [Test]
        public void FastClone_複製後に入力をDisposeしても複製は有効なまま読める() {
            var src = CreateSolidBitmap(4, 4, PixelFormat.Format24bppRgb, Color.FromArgb(11, 22, 33));
            Bitmap dst = BitmapUtil.FastClone(src);
            src.Dispose();

            try {
                Assert.AreEqual(4, dst.Width, "src Dispose 後も dst の幅が読めること");
                Assert.AreEqual(4, dst.Height, "src Dispose 後も dst の高さが読めること");

                Color px = dst.GetPixel(0, 0);
                Assert.AreEqual(11, px.R, "src Dispose 後も dst の画素(R)が読めること");
                Assert.AreEqual(22, px.G, "src Dispose 後も dst の画素(G)が読めること");
                Assert.AreEqual(33, px.B, "src Dispose 後も dst の画素(B)が読めること");
            } finally {
                dst.Dispose();
            }
        }

        /// <summary>
        /// FastClone_複製の画素を書き換えても入力の画素は変化しない
        /// </summary>
        [Test]
        public void FastClone_複製の画素を書き換えても入力の画素は変化しない() {
            using (var src = CreateSolidBitmap(4, 4, PixelFormat.Format24bppRgb, Color.FromArgb(50, 60, 70)))
            using (var dst = BitmapUtil.FastClone(src)) {
                dst.SetPixel(0, 0, Color.FromArgb(0, 0, 0));

                Color srcPx = src.GetPixel(0, 0);
                Assert.AreEqual(50, srcPx.R, "dst 変更後も src の R は不変");
                Assert.AreEqual(60, srcPx.G, "dst 変更後も src の G は不変");
                Assert.AreEqual(70, srcPx.B, "dst 変更後も src の B は不変");
            }
        }

        // ---------------------------------------------------------------
        // 仕様4: 入力を破壊しない(呼び出し前後で src の画素が不変)
        // ---------------------------------------------------------------

        /// <summary>
        /// FastClone_呼び出し前後でsrcの画素が変化しない
        /// </summary>
        [Test]
        public void FastClone_呼び出し前後でsrcの画素が変化しない() {
            using (var src = CreateGradientBitmap(7, 5, PixelFormat.Format24bppRgb)) {
                // 呼び出し前の画素を保持
                Color before00 = src.GetPixel(0, 0);
                Color beforeCorner = src.GetPixel(src.Width - 1, src.Height - 1);

                using (var dst = BitmapUtil.FastClone(src)) {
                    Assert.IsNotNull(dst);
                }

                Color after00 = src.GetPixel(0, 0);
                Color afterCorner = src.GetPixel(src.Width - 1, src.Height - 1);

                Assert.AreEqual(before00, after00, "FastClone 呼び出し前後で (0,0) の画素が不変であること");
                Assert.AreEqual(beforeCorner, afterCorner, "FastClone 呼び出し前後で末尾画素が不変であること");
            }
        }

        // ---------------------------------------------------------------
        // 仕様5: ストライドのパディング(幅が4の倍数でない24bppRgb)
        // ---------------------------------------------------------------

        /// <summary>
        /// FastClone_幅が4の倍数でない24bppRgb_全画素が一致する(行末パディングを除外して比較)
        /// </summary>
        [Test]
        public void FastClone_幅が4の倍数でない24bppRgb_全画素が一致する() {
            // 幅5px * 3byte = 15byte → 4byte境界にするため1byteパディングが入るサイズ
            using (var src = CreateGradientBitmap(5, 3, PixelFormat.Format24bppRgb))
            using (var dst = BitmapUtil.FastClone(src)) {
                AssertPixelsEqual(src, dst);
            }
        }

        /// <summary>
        /// FastClone_幅が4の倍数でない24bppRgb_サイズが一致する
        /// </summary>
        [Test]
        public void FastClone_幅が4の倍数でない24bppRgb_サイズが一致する() {
            using (var src = CreateGradientBitmap(5, 3, PixelFormat.Format24bppRgb))
            using (var dst = BitmapUtil.FastClone(src)) {
                Assert.AreEqual(5, dst.Width);
                Assert.AreEqual(3, dst.Height);
            }
        }

        // ---------------------------------------------------------------
        // 仕様6: インデックスカラーのフォールバック
        // ---------------------------------------------------------------

        /// <summary>
        /// FastClone_8bppIndexed_色が等価な独立コピーを返す:
        ///
        /// 8bppIndexed は LockBits 単純コピーではパレットを保持できないため,
        /// `new Bitmap(src)`(GDI+ フルコピー)にフォールバックする。
        /// このフォールバックは GDI+ の仕様上,返り値の PixelFormat を
        /// Format32bppArgb に変換する(Format8bppIndexed のままにはならない)。
        /// そのため本テストでは PixelFormat の一致は検証せず,
        /// 「例外を投げない」「入力とは別インスタンス」「サイズが一致する」
        /// 「解決済みの色(GetPixel が返すパレット適用後の ARGB)が画素ごとに一致する」
        /// という,このフォールバックが満たすべき妥当な契約を検証する。
        /// </summary>
        [Test]
        public void FastClone_8bppIndexed_色が等価な独立コピーを返す() {
            using (var src = new Bitmap(4, 4, PixelFormat.Format8bppIndexed)) {
                // パレットを設定(グレースケール)
                ColorPalette palette = src.Palette;
                for (int i = 0; i < palette.Entries.Length; i++) {
                    palette.Entries[i] = Color.FromArgb(i, i, i);
                }
                src.Palette = palette;

                // 画素インデックスを設定
                SetIndexedPixel(src, 0, 0, 5);
                SetIndexedPixel(src, 1, 1, 200);
                SetIndexedPixel(src, 3, 3, 255);

                Bitmap dst = null;
                Assert.DoesNotThrow(() => dst = BitmapUtil.FastClone(src),
                    "8bppIndexed を渡しても例外をスローしないこと");

                try {
                    Assert.IsNotNull(dst, "8bppIndexed のフォールバックコピーは null であってはならない");
                    Assert.AreNotSame(src, dst, "入力とは別インスタンスであること");
                    Assert.AreEqual(src.Width, dst.Width, "幅が一致すること");
                    Assert.AreEqual(src.Height, dst.Height, "高さが一致すること");

                    // 解決済みの色(パレット適用後の ARGB)が画素ごとに一致すること
                    Assert.AreEqual(src.GetPixel(0, 0), dst.GetPixel(0, 0), "(0,0) の色が一致すること");
                    Assert.AreEqual(src.GetPixel(1, 1), dst.GetPixel(1, 1), "(1,1) の色が一致すること");
                    Assert.AreEqual(src.GetPixel(3, 3), dst.GetPixel(3, 3), "(3,3) の色が一致すること");

                    // 独立性: src を Dispose 後も dst の画素が読めること
                } finally {
                    if (dst != null) dst.Dispose();
                }
            }
        }

        /// <summary>
        /// FastClone_8bppIndexed_複製後に入力をDisposeしても複製の色は読める:
        /// インデックスフォールバック経路でも独立性(クロススレッド Dispose 耐性)が
        /// 保たれることを確認する。
        /// </summary>
        [Test]
        public void FastClone_8bppIndexed_複製後に入力をDisposeしても複製の色は読める() {
            var src = new Bitmap(2, 2, PixelFormat.Format8bppIndexed);
            ColorPalette palette = src.Palette;
            palette.Entries[9] = Color.FromArgb(9, 9, 9);
            src.Palette = palette;
            SetIndexedPixel(src, 0, 0, 9);

            Bitmap dst = BitmapUtil.FastClone(src);
            Color expected = src.GetPixel(0, 0);
            src.Dispose();

            try {
                Color actual = dst.GetPixel(0, 0);
                Assert.AreEqual(expected, actual, "src Dispose 後も dst の色が読めること");
            } finally {
                dst.Dispose();
            }
        }

        // ---------------------------------------------------------------
        // 仕様7: null 入力
        // ---------------------------------------------------------------

        /// <summary>
        /// FastClone_null入力_nullを返す
        /// </summary>
        [Test]
        public void FastClone_null入力_nullを返す() {
            Bitmap result = BitmapUtil.FastClone(null);
            Assert.IsNull(result, "null 入力に対して null を返すこと(例外をスローしない)");
        }

        // ---------------------------------------------------------------
        // ヘルパー
        // ---------------------------------------------------------------

        /// <summary>
        /// 単色の Bitmap を生成する
        /// </summary>
        private static Bitmap CreateSolidBitmap(int width, int height, PixelFormat format, Color color) {
            var bmp = new Bitmap(width, height, format);
            using (var g = Graphics.FromImage(bmp)) {
                using (var brush = new SolidBrush(color)) {
                    g.FillRectangle(brush, 0, 0, width, height);
                }
            }
            return bmp;
        }

        /// <summary>
        /// 決定的なグラデーション(座標に依存する画素値)を持つ Bitmap を GetPixel/SetPixel で生成する。
        /// GDI+ の描画経路を通さず,画素値を直接指定するため決定的。
        /// </summary>
        private static Bitmap CreateGradientBitmap(int width, int height, PixelFormat format) {
            var bmp = new Bitmap(width, height, format);
            for (int y = 0; y < height; y++) {
                for (int x = 0; x < width; x++) {
                    int r = (x * 37 + y * 11) % 256;
                    int g = (x * 5 + y * 53) % 256;
                    int b = (x * 71 + y * 3) % 256;
                    int a = format == PixelFormat.Format32bppArgb ? (x * 13 + y * 17) % 256 : 255;
                    bmp.SetPixel(x, y, Color.FromArgb(a, r, g, b));
                }
            }
            return bmp;
        }

        /// <summary>
        /// 8bppIndexed Bitmap の指定座標にパレットインデックスを設定する(LockBits 経由)
        /// </summary>
        private static void SetIndexedPixel(Bitmap bmp, int x, int y, byte index) {
            var rect = new Rectangle(x, y, 1, 1);
            BitmapData data = bmp.LockBits(rect, ImageLockMode.WriteOnly, bmp.PixelFormat);
            try {
                System.Runtime.InteropServices.Marshal.WriteByte(data.Scan0, index);
            } finally {
                bmp.UnlockBits(data);
            }
        }

        /// <summary>
        /// 2つの Bitmap が全画素で一致することを検証する(GetPixel による代表的座標比較でなく全画素走査)。
        /// LockBits で生バイトを取得し,行ごとに有効バイト幅(パディング除外)だけを比較する。
        /// </summary>
        private static void AssertPixelsEqual(Bitmap expected, Bitmap actual) {
            Assert.AreEqual(expected.Width, actual.Width, "幅が一致すること(画素比較の前提)");
            Assert.AreEqual(expected.Height, actual.Height, "高さが一致すること(画素比較の前提)");
            Assert.AreEqual(expected.PixelFormat, actual.PixelFormat, "PixelFormat が一致すること(画素比較の前提)");

            var rect = new Rectangle(0, 0, expected.Width, expected.Height);
            BitmapData expData = expected.LockBits(rect, ImageLockMode.ReadOnly, expected.PixelFormat);
            BitmapData actData = actual.LockBits(rect, ImageLockMode.ReadOnly, actual.PixelFormat);
            try {
                int bytesPerPixel = Image.GetPixelFormatSize(expected.PixelFormat) / 8;
                int validBytesPerRow = expected.Width * bytesPerPixel;

                var expRow = new byte[validBytesPerRow];
                var actRow = new byte[validBytesPerRow];

                for (int y = 0; y < expected.Height; y++) {
                    System.Runtime.InteropServices.Marshal.Copy(
                        IntPtr.Add(expData.Scan0, y * expData.Stride), expRow, 0, validBytesPerRow);
                    System.Runtime.InteropServices.Marshal.Copy(
                        IntPtr.Add(actData.Scan0, y * actData.Stride), actRow, 0, validBytesPerRow);

                    for (int i = 0; i < validBytesPerRow; i++) {
                        Assert.AreEqual(expRow[i], actRow[i],
                            $"行 y={y} のバイト位置 {i}(パディング除外後)が一致すること");
                    }
                }
            } finally {
                expected.UnlockBits(expData);
                actual.UnlockBits(actData);
            }
        }
    }
}