diff --git a/CLAUDE.md b/CLAUDE.md index fd8f482..7e76617 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: テスト方針を 4 層モデルに改訂し,実機確認を減らす移行計画(PLAN_05)を策定した +最新: /auto-audit(全観点)で見つけたバグ 7 件を再現テスト付きで修正し main に取り込んだ(要対応 26 件は報告台帳) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git a/TIASshot.Tests/BitmapUtilTests.cs b/TIASshot.Tests/BitmapUtilTests.cs index 0198304..0e29218 100644 --- a/TIASshot.Tests/BitmapUtilTests.cs +++ b/TIASshot.Tests/BitmapUtilTests.cs @@ -262,6 +262,44 @@ } // --------------------------------------------------------------- + // 仕様8: LockBits が例外を投げた場合も複製先 Bitmap を解放する + // --------------------------------------------------------------- + + /// + /// FastClone_入力が既にロック中でLockBitsが失敗_複製先Bitmapのネイティブメモリを残さない: + /// 入力を先に LockBits しておくと FastClone 内の src.LockBits が例外を投げる. + /// このとき確保済みの複製先 Bitmap(大きい画素バッファ)を Dispose せずに例外を伝播すると, + /// ネイティブメモリが GC のファイナライザまで残る.GC を挟まずにプロセスの + /// プライベートメモリ増分を測り,複製先バッファ相当(約 64MB)が残っていないことを確認する. + /// + [Test] + public void FastClone_入力が既にロック中でLockBitsが失敗_複製先Bitmapのネイティブメモリを残さない() { + const int size = 4096; // 32bppArgb で 4096*4096*4 = 64MB + using (var src = new Bitmap(size, size, PixelFormat.Format32bppArgb)) { + var rect = new Rectangle(0, 0, size, size); + BitmapData held = src.LockBits(rect, ImageLockMode.ReadOnly, src.PixelFormat); + try { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + long before = System.Diagnostics.Process.GetCurrentProcess().PrivateMemorySize64; + + Assert.Throws((Action)(() => BitmapUtil.FastClone(src)), + "入力がロック中なら LockBits の例外が呼び出し元へ伝播すること"); + + long after = System.Diagnostics.Process.GetCurrentProcess().PrivateMemorySize64; + long leaked = after - before; + Assert.That(leaked, Is.LessThan(32L * 1024 * 1024), + $"例外時に複製先 Bitmap が解放されていない(プライベートメモリ増分 {leaked / (1024 * 1024)}MB)"); + } finally { + src.UnlockBits(held); + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + } + } + + // --------------------------------------------------------------- // ヘルパー // --------------------------------------------------------------- diff --git a/TIASshot.Tests/ChartDetectorTests.cs b/TIASshot.Tests/ChartDetectorTests.cs index 99eb72a..9281b0f 100644 --- a/TIASshot.Tests/ChartDetectorTests.cs +++ b/TIASshot.Tests/ChartDetectorTests.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using NUnit.Framework; using OpenCvSharp; @@ -206,10 +208,59 @@ } } + /// + /// 固定判定成立後の検出(ホモグラフィ計算・ワープ)で生成する中間 Mat を呼び出し内で解放し, + /// ネイティブメモリを GC ファイナライザ任せで滞留させないこと + /// + /// GC を止めた状態(NoGCRegion)でマスク生成まで進む検出を繰り返し,プライベートメモリの増分を見る. + /// 中間 Mat(とくに 810 x 1545 x 3 のワープ画像 約 3.75MB)が未解放だと呼び出し回数に比例して増える. + /// 返されたマスクはテスト側で解放する.Windows ヒープは大きなブロックを解放時に OS へ返すため, + /// 解放されていれば増分はほぼ 0 になる. + /// + [Test] + public void DetectChart_固定判定成立後の検出を繰り返す_中間Matのネイティブメモリを滞留させない() { + const int Iterations = 10; + const long LeakThresholdBytes = 20L * 1024 * 1024; // 未解放なら約 37MB 増える + var detector = new ChartDetector(RecordMessage); + using (var img = CreateChartImage(Marker40X, Marker40Y, Marker41X, Marker41Y)) { + // Arrange: 固定判定を成立させ,ネイティブ側の初回確保(検出器の内部バッファ等)も済ませる + DisposeMasks(DetectUntilStable(detector, img)); + for (int i = 0; i < 3; i++) DisposeMasks(detector.DetectChart(img)); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.That(GC.TryStartNoGCRegion(64L * 1024 * 1024), Is.True, "NoGCRegion を開始できること(前提)"); + long before, after; + try { + before = GetPrivateBytes(); + // Act + for (int i = 0; i < Iterations; i++) { + var masks = detector.DetectChart(img); + Assert.That(masks, Has.Count.EqualTo(ExpectedMaskCount), "毎回マスク生成まで進むこと(前提)"); + DisposeMasks(masks); + } + after = GetPrivateBytes(); + } finally { + if (System.Runtime.GCSettings.LatencyMode == System.Runtime.GCLatencyMode.NoGCRegion) GC.EndNoGCRegion(); + } + + // Assert + Assert.That(after - before, Is.LessThan(LeakThresholdBytes), + $"{Iterations} 回の検出でプライベートメモリが {(after - before) / 1024 / 1024}MB 増えた(中間 Mat の未解放)"); + } + } + // --------------------------------------------------------------- // ヘルパー // --------------------------------------------------------------- + private static long GetPrivateBytes() { + using (var process = Process.GetCurrentProcess()) { + return process.PrivateMemorySize64; + } + } + private void RecordMessage(string message) { _messages.Add(message); } diff --git a/TIASshot.Tests/ColorCorrectorTests.cs b/TIASshot.Tests/ColorCorrectorTests.cs index 8642ef9..c57822e 100644 --- a/TIASshot.Tests/ColorCorrectorTests.cs +++ b/TIASshot.Tests/ColorCorrectorTests.cs @@ -542,6 +542,56 @@ } /// + /// ExtendMat の戻り値のバッファを共有する保持用ヘッダを作り,そのバッファの + /// ネイティブ参照カウント(cv::UMatData::refcount)を読めるようにするテスト用サブクラス. + /// 挙動は変えない.オフセットは x64 / OpenCV 4.x の cv::Mat・cv::UMatData のレイアウト + /// (cv::Mat::u = +56,UMatData::refcount = +20)に依存するため,捕捉直後の値で前提を検証する. + /// + private class ExtendMatRefCountCorrector : ColorCorrector { + public Mat Holder; + public System.IntPtr UMatData; + public int RefCountAtCapture; + + public ExtendMatRefCountCorrector() : base(null, null, new int[] { 4 }) { } + + internal override Mat ExtendMat(Mat src, int channels) { + var result = base.ExtendMat(src, channels); + Holder = new Mat(result, new Rect(0, 0, result.Cols, result.Rows)); + UMatData = System.Runtime.InteropServices.Marshal.ReadIntPtr(result.CvPtr, 56); + RefCountAtCapture = ReadRefCount(); + return result; + } + + public int ReadRefCount() { + return System.Runtime.InteropServices.Marshal.ReadInt32(UMatData, 20); + } + } + + /// + /// ConvertImage: 行列積の一時 MatExpr を解放し,中間拡張 Mat のネイティブバッファへの参照を + /// 呼び出し完了時に残さないこと(MatExpr を GC ファイナライザ任せにすると,拡張 Mat の + /// 大きなバッファが GC まで解放されずピークメモリが膨らむ) + /// + [Test] + public void ConvertImage_通常入力_行列積のMatExprが拡張Matのバッファ参照を残さない() { + // Arrange + using (var src = new Mat(2, 2, MatType.CV_8UC3, new Scalar(50, 100, 150))) + using (var conv = CreateIdentityLikeConvMat()) { + var corrector = new ExtendMatRefCountCorrector(); + + // Act + using (var dst = corrector.ConvertImage(src, conv)) + using (corrector.Holder) { + // Assert + Assert.That(corrector.RefCountAtCapture, Is.EqualTo(2), + "捕捉直後は拡張 Mat と保持用ヘッダの 2 参照(オフセット前提の確認)"); + Assert.That(corrector.ReadRefCount(), Is.EqualTo(1), + "ConvertImage 完了後は保持用ヘッダの参照のみが残る(MatExpr が参照を保持しない)"); + } + } + } + + /// /// CalcConvertMatrix: ExtendMat のパススルー入力(3 列相当でない Mat は src をそのまま返す)でも /// 入力 src を解放しないこと(無条件 Dispose による入力破壊の回帰防止) /// diff --git a/TIASshot.Tests/GainCalcCalcGainValueTests.cs b/TIASshot.Tests/GainCalcCalcGainValueTests.cs index 2a331ca..1aa3035 100644 --- a/TIASshot.Tests/GainCalcCalcGainValueTests.cs +++ b/TIASshot.Tests/GainCalcCalcGainValueTests.cs @@ -115,6 +115,18 @@ Assert.That(result, Is.EqualTo(133)); } + /// + /// CalcGainValue_現在値0で比率がInfinity_範囲内に収まる: + /// ホワイトバランスが下限 0 に張り付いた状態で白パッチの計測値が 0 になると + /// 0 * Infinity = NaN となり範囲判定をすり抜ける.未定義値(int.MinValue)を + /// 設定せず,必ず [rangeMin, rangeMax] に収まること + /// + [Test] + public void CalcGainValue_現在値0で比率がInfinity_範囲内に収まる() { + int result = GainCalc.CalcGainValue(0, float.PositiveInfinity, 0, 255); + Assert.That(result, Is.InRange(0, 255)); + } + // --------------------------------------------------------------- // CalcRatio との結合(実際の校正経路の再現) // --------------------------------------------------------------- diff --git a/TIASshot.Tests/ImageStatsTests.cs b/TIASshot.Tests/ImageStatsTests.cs index 8032426..85440f6 100644 --- a/TIASshot.Tests/ImageStatsTests.cs +++ b/TIASshot.Tests/ImageStatsTests.cs @@ -402,6 +402,51 @@ } /// + /// BuildJson_string値中の改行とタブ_エスケープされる: + /// RFC 8259 §7 により制御文字は生で出力できないため '\n' '\t' にエスケープされること + /// + [Test] + public void BuildJson_string値中の改行とタブ_エスケープされる() { + var meta = new Dictionary { + { "device_name", "DFK\n23\tUX" }, + }; + + string json = ImageStats.BuildJson(meta, null); + + Assert.That(json, Does.Contain("\"DFK\\n23\\tUX\""), "改行は \\n,タブは \\t にエスケープされること"); + } + + /// + /// BuildJson_string値中のその他の制御文字_uエスケープされる: + /// 短縮表記を持たない 0x20 未満の制御文字は '\u00XX' 形式にエスケープされること + /// + [Test] + public void BuildJson_string値中のその他の制御文字_uエスケープされる() { + var meta = new Dictionary { + { "serial_number", "A\u0001B\u001fC" }, + }; + + string json = ImageStats.BuildJson(meta, null); + + Assert.That(json, Does.Contain("\"A\\u0001B\\u001fC\""), "制御文字は \\u00XX にエスケープされること"); + } + + /// + /// BuildJson_string値中の非ASCII文字_そのまま出力される: + /// 制御文字以外(日本語など)はエスケープせずそのまま出力されること(特性化) + /// + [Test] + public void BuildJson_string値中の非ASCII文字_そのまま出力される() { + var meta = new Dictionary { + { "shading_correction", "未補正(LSC 保留)" }, + }; + + string json = ImageStats.BuildJson(meta, null); + + Assert.That(json, Does.Contain("\"未補正(LSC 保留)\""), "非 ASCII 文字はそのまま出力されること"); + } + + /// /// BuildJson_statsリスト_frame_statsキーと各フレームのフィールドが生成される: /// 1フレーム分の FrameStats を与えたとき、"frame_stats" 配列が生成され /// "frame", "saturated_ratio", "p99_luminance", "mean_B/G/R", "max_B/G/R" が含まれること diff --git a/TIASshot.Tests/IoUtilWriteInfoTests.cs b/TIASshot.Tests/IoUtilWriteInfoTests.cs new file mode 100644 index 0000000..dff11c1 --- /dev/null +++ b/TIASshot.Tests/IoUtilWriteInfoTests.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; +using NUnit.Framework; + +namespace TIASshot.Tests { + /// + /// IoUtil.WriteInfo(撮影情報 Info.csv の書き出し)のテスト. + /// 値にカンマ・ダブルクォート・改行が含まれても 1 項目が 1 行(キー,値 の 2 列)に保たれ, + /// RFC 4180 に従ってクォートされることを検証する.それ以外の値は従来どおりそのまま出力する. + /// + [TestFixture] + public class IoUtilWriteInfoTests { + + private string _csvPath; + + [SetUp] + public void SetUp() { + _csvPath = Path.GetTempFileName(); + } + + [TearDown] + public void TearDown() { + if (File.Exists(_csvPath)) File.Delete(_csvPath); + } + + /// + /// 本番(RunShotLoop)と同じく new StreamWriter(path) で WriteInfo を呼び, + /// 先頭の撮影日時行を除いた本文を返す + /// + private string WriteAndReadBody(Dictionary info) { + using (var sw = new StreamWriter(_csvPath)) { + IoUtil.WriteInfo(sw, info); + } + var text = File.ReadAllText(_csvPath, Encoding.UTF8); + var firstNewLine = text.IndexOf("\r\n"); + return text.Substring(firstNewLine + 2); + } + + [Test] + public void WriteInfo_特殊文字を含まない値_従来どおりそのまま出力する() { + var info = new Dictionary { + { "データ名", "subject01" }, + { "Exposure(ms)", "12.5" }, + { "校正データ", @"C:\Data\2026-10-06\10_00_00-校正" }, + }; + + var body = WriteAndReadBody(info); + + Assert.That(body, Is.EqualTo( + "データ名,subject01\r\n" + + "Exposure(ms),12.5\r\n" + + "校正データ,C:\\Data\\2026-10-06\\10_00_00-校正\r\n")); + } + + [Test] + public void WriteInfo_値にカンマを含む_ダブルクォートで囲む() { + var info = new Dictionary { { "データ名", "山田,太郎" } }; + + var body = WriteAndReadBody(info); + + Assert.That(body, Is.EqualTo("データ名,\"山田,太郎\"\r\n")); + } + + [Test] + public void WriteInfo_値にダブルクォートを含む_二重化してダブルクォートで囲む() { + var info = new Dictionary { { "データ名", "a\"b" } }; + + var body = WriteAndReadBody(info); + + Assert.That(body, Is.EqualTo("データ名,\"a\"\"b\"\r\n")); + } + + [Test] + public void WriteInfo_値に改行を含む_ダブルクォートで囲み1レコードに保つ() { + var info = new Dictionary { { "データ名", "a\r\nb" } }; + + var body = WriteAndReadBody(info); + + Assert.That(body, Is.EqualTo("データ名,\"a\r\nb\"\r\n")); + } + + [Test] + public void WriteInfo_値がnull_空欄として出力する() { + var info = new Dictionary { { "データ名", null } }; + + var body = WriteAndReadBody(info); + + Assert.That(body, Is.EqualTo("データ名,\r\n")); + } + } +} diff --git a/TIASshot.Tests/SelfTestRunnerTests.cs b/TIASshot.Tests/SelfTestRunnerTests.cs index 6962d60..1a62ab8 100644 --- a/TIASshot.Tests/SelfTestRunnerTests.cs +++ b/TIASshot.Tests/SelfTestRunnerTests.cs @@ -86,6 +86,18 @@ Assert.That(SelfTestRunner.ExitTimeout, Is.EqualTo(3)); } + [TestCase("", TestName = "Run_出力先が空文字_例外を投げず終了コード2")] + [TestCase("a\0b.txt", TestName = "Run_出力先にNUL文字_例外を投げず終了コード2")] + [TestCase(@"C:\tmp\a:b.txt", TestName = "Run_出力先にドライブ以外のコロン_例外を投げず終了コード2")] + public void Run_不正な出力先_セルフテスト自体の異常として2を返す(string outValue) { + // 不正な --selftest-out は結果ファイルを作成できない場合と同じく「セルフテスト自体の異常」(2). + // 結果ファイルの準備段階で返るため,フォーム・偽カメラ・ウォッチドッグは起動しない + var args = new[] { "TIASshot.exe", "--selftest", "--selftest-out", outValue }; + var exitCode = -1; + Assert.DoesNotThrow((System.Action)(() => exitCode = SelfTestRunner.Run(args))); + Assert.That(exitCode, Is.EqualTo(SelfTestRunner.ExitError)); + } + [Test] public void フラグ名_仕様どおりselftest() { Assert.That(SelfTestRunner.FlagSelfTest, Is.EqualTo("--selftest")); diff --git a/TIASshot.Tests/TIASshot.Tests.csproj b/TIASshot.Tests/TIASshot.Tests.csproj index 2ab809f..504bc4a 100644 --- a/TIASshot.Tests/TIASshot.Tests.csproj +++ b/TIASshot.Tests/TIASshot.Tests.csproj @@ -93,6 +93,7 @@ + diff --git a/TIASshot/Cameras/GainCalc.cs b/TIASshot/Cameras/GainCalc.cs index 9854e5d..cf823f5 100644 --- a/TIASshot/Cameras/GainCalc.cs +++ b/TIASshot/Cameras/GainCalc.cs @@ -34,6 +34,9 @@ // float→int キャストは範囲外・非有限値(Infinity)で未定義値になるため, // キャスト前に double のまま範囲判定する double value = 0.5 + (double)(currentValue * ratio); + // 現在値 0 に比率 Infinity を掛けると NaN になり範囲判定をすり抜けるため, + // 比率 NaN と同様に更新量を決められないものとして現在値を維持する + if (double.IsNaN(value)) return currentValue; if (value < rangeMin) return rangeMin; if (value > rangeMax) return rangeMax; return (int)value; diff --git a/TIASshot/ChartDetection/ChartDetector.cs b/TIASshot/ChartDetection/ChartDetector.cs index 1b9fafd..eb4f2c1 100644 --- a/TIASshot/ChartDetection/ChartDetector.cs +++ b/TIASshot/ChartDetection/ChartDetector.cs @@ -81,31 +81,32 @@ if (_detectionCount < Config.GetInt("Calib/ChartSetCount")) return null; // ホモグラフィの計算 - var matPtsPict = Mat.FromArray(ptsPict); - var matPtsModel = Mat.FromArray(ptsModel); - var matH = Cv2.FindHomography(matPtsModel, matPtsPict); - var imgF = new Mat(1545, 810, MatType.CV_8UC3); - Cv2.WarpPerspective(img, imgF, matH, imgF.Size()); + using (var matPtsPict = Mat.FromArray(ptsPict)) + using (var matPtsModel = Mat.FromArray(ptsModel)) + using (var matH = Cv2.FindHomography(matPtsModel, matPtsPict)) + using (var imgF = new Mat(1545, 810, MatType.CV_8UC3)) { + Cv2.WarpPerspective(img, imgF, matH, imgF.Size()); - // チャートマスク作成 - var chartMasks = new List(); - var roiSize = ptsPict.Count < 8 ? 60 : 80; - for (int i = 0; i < 24; i++) { - var row = i % 6; - var col = i / 6; - var x = 581 - col * 144 + (ptsPict.Count < 8 ? 10 : 0); - var y = 318 + row * 144 + (ptsPict.Count < 8 ? 10 : 0); - var roi = new Rect(x, y, roiSize, roiSize); - using (var mask = new Mat(1545, 810, MatType.CV_8U)) { - Cv2.Rectangle(mask, roi, new Scalar(255), Cv2.FILLED); - var maskF = new Mat(img.Size(), MatType.CV_8U); - Cv2.WarpPerspective(mask, maskF, matH, maskF.Size()); - chartMasks.Add(maskF); + // チャートマスク作成 + var chartMasks = new List(); + var roiSize = ptsPict.Count < 8 ? 60 : 80; + for (int i = 0; i < 24; i++) { + var row = i % 6; + var col = i / 6; + var x = 581 - col * 144 + (ptsPict.Count < 8 ? 10 : 0); + var y = 318 + row * 144 + (ptsPict.Count < 8 ? 10 : 0); + var roi = new Rect(x, y, roiSize, roiSize); + using (var mask = new Mat(1545, 810, MatType.CV_8U)) { + Cv2.Rectangle(mask, roi, new Scalar(255), Cv2.FILLED); + var maskF = new Mat(img.Size(), MatType.CV_8U); + Cv2.WarpPerspective(mask, maskF, matH, maskF.Size()); + chartMasks.Add(maskF); + } } - } - _showMessage("舌診チャート検出 校正中"); - return chartMasks; + _showMessage("舌診チャート検出 校正中"); + return chartMasks; + } } } } diff --git a/TIASshot/ColorCorrection/ColorCorrector.cs b/TIASshot/ColorCorrection/ColorCorrector.cs index 4c37829..de4458b 100644 --- a/TIASshot/ColorCorrection/ColorCorrector.cs +++ b/TIASshot/ColorCorrection/ColorCorrector.cs @@ -214,7 +214,8 @@ } using (var flatten = src.Reshape(3, src.Height * src.Width)) using (var extended = ExtendMat(flatten, conv.Rows)) - using (var converted = (extended * conv).ToMat()) + using (var product = extended * conv) + using (var converted = product.ToMat()) using (var convertedImage = converted.Reshape(3, src.Height)) { //Clipping to 8bit range //unsafe { diff --git a/TIASshot/SelfTest/SelfTestRunner.cs b/TIASshot/SelfTest/SelfTestRunner.cs index 39dbc15..00c11ef 100644 --- a/TIASshot/SelfTest/SelfTestRunner.cs +++ b/TIASshot/SelfTest/SelfTestRunner.cs @@ -77,11 +77,13 @@ var stamp = $"{DateTime.Now:yyyyMMdd-HHmmss}-{Process.GetCurrentProcess().Id}"; var tempRoot = Path.Combine(Path.GetTempPath(), "TIASshot-selftest"); var workDir = Path.Combine(tempRoot, $"work-{stamp}"); - var outPath = Path.GetFullPath(GetArgValue(args, FlagOut) ?? Path.Combine(tempRoot, $"result-{stamp}.txt")); + var outPath = GetArgValue(args, FlagOut) ?? Path.Combine(tempRoot, $"result-{stamp}.txt"); var keep = HasFlag(args, FlagKeep); SelfTestReport report; try { + // 不正な --selftest-out(空文字・不正文字等)も結果ファイルを作成できない場合と同じく異常(2)とする + outPath = Path.GetFullPath(outPath); report = new SelfTestReport(outPath); } catch (Exception ex) { Debug.WriteLine($"セルフテスト結果ファイルを作成できません: {outPath}: {ex}"); diff --git a/TIASshot/Utils/BitmapUtil.cs b/TIASshot/Utils/BitmapUtil.cs index 82d10b6..57818c9 100644 --- a/TIASshot/Utils/BitmapUtil.cs +++ b/TIASshot/Utils/BitmapUtil.cs @@ -44,6 +44,7 @@ BitmapData srcData = null; BitmapData dstData = null; + bool succeeded = false; try { srcData = src.LockBits(rect, ImageLockMode.ReadOnly, src.PixelFormat); dstData = dst.LockBits(rect, ImageLockMode.WriteOnly, dst.PixelFormat); @@ -58,10 +59,13 @@ IntPtr dstRow = IntPtr.Add(dstData.Scan0, y * dstData.Stride); CopyMemory(dstRow, srcRow, count); } + succeeded = true; } finally { // src は ReadOnly ロックのみなので UnlockBits は元データを変更しない(src は破損しない) if (srcData != null) src.UnlockBits(srcData); if (dstData != null) dst.UnlockBits(dstData); + // LockBits 等が例外を投げた場合,呼び出し元に渡らない dst をここで解放する(GC 任せにしない) + if (!succeeded) dst.Dispose(); } return dst; diff --git a/TIASshot/Utils/ImageStats.cs b/TIASshot/Utils/ImageStats.cs index 52b8bae..f98d4b7 100644 --- a/TIASshot/Utils/ImageStats.cs +++ b/TIASshot/Utils/ImageStats.cs @@ -194,6 +194,12 @@ foreach (var c in s) { if (c == '"') sb.Append("\\\""); else if (c == '\\') sb.Append("\\\\"); + else if (c == '\n') sb.Append("\\n"); + else if (c == '\r') sb.Append("\\r"); + else if (c == '\t') sb.Append("\\t"); + else if (c == '\b') sb.Append("\\b"); + else if (c == '\f') sb.Append("\\f"); + else if (c < 0x20) sb.Append("\\u").Append(((int)c).ToString("x4")); // RFC 8259 §7: 制御文字は必ずエスケープ else sb.Append(c); } sb.Append('"'); diff --git a/TIASshot/Utils/IoUtil.cs b/TIASshot/Utils/IoUtil.cs index 95f9d16..a731ece 100644 --- a/TIASshot/Utils/IoUtil.cs +++ b/TIASshot/Utils/IoUtil.cs @@ -131,18 +131,33 @@ } /// - /// 設定値をCSVファイルに書き込む + /// 設定値をCSVファイルに書き込む. + /// 値(ユーザー入力のデータ名・保存フォルダのパス等)にカンマ・ダブルクォート・改行が + /// 含まれても行が崩れないよう,RFC 4180 に従ってクォートする(該当しない値は従来どおり). /// /// /// public static void WriteInfo(StreamWriter sw, Dictionary info) { sw.WriteLine($"撮影日時,{DateTime.Now:yyyy/MM/dd HH:mm:ss}"); // 撮影日時を追加 foreach (var kvp in info) { - sw.WriteLine($"{kvp.Key},{kvp.Value}"); + sw.WriteLine($"{EscapeCsvField(kvp.Key)},{EscapeCsvField(kvp.Value)}"); } } /// + /// CSV の 1 フィールドを RFC 4180 に従ってエスケープする. + /// カンマ・ダブルクォート・CR・LF を含む場合のみ全体をダブルクォートで囲み, + /// 内部のダブルクォートを二重化する.それ以外はそのまま返す(null は空文字). + /// + /// フィールド値(null 可) + /// エスケープ済みのフィールド + private static string EscapeCsvField(string field) { + if (string.IsNullOrEmpty(field)) return ""; + if (field.IndexOfAny(new[] { ',', '"', '\r', '\n' }) < 0) return field; + return "\"" + field.Replace("\"", "\"\"") + "\""; + } + + /// /// 効果音を再生する /// /// diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index c0525b9..d3e2068 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -167,3 +167,9 @@ - 動機: 同日の `/auto-audit` で報告した 26 件の多くが「`CameraBase`・`Form1`・カメラ層で再現テストを書けない」ことを理由に自動修正できなかった.保存失敗が成功扱いになる件など,データ欠損に直結するものも含まれていた.偽カメラとヘッドレス `Form1` はセルフテスト用にすでにあり,自動化できない範囲は思っていたより狭かった. - 設計判断: テストプロジェクトへの `System.Windows.Forms` 参照の追加を許可した.`CameraBase` は偽カメラ+ヘッドレス `Form1` で検証し,カメラ SDK はモックせずに判断ロジックを切り出す(Humble Object).実機録画データ(チャート・白板のみ.舌は個人情報のため除外)は Git LFS で管理する.層②は全体 30 秒・1 件 15 秒を上限とし,CI が無いため `/verify` に常に含める. - 後段: フェーズ 1(WinForms 参照の追加・STA ハーネスの作成・「保存失敗が成功扱い」の赤テストと修正)を `/implement` で行う.`fix/auto-audit` の取り込みは実機確認待ち. + +- [x] **/auto-audit(全観点)の自動修正 7 件を取り込み** (2026-10-06): バグ・脆弱性の巡回監査ループを初めて全観点で回し,再現テスト(修正前に赤)を書けた 7 件を修正した.内訳は NaN ゲインの設定防止,白板 JSON の制御文字エスケープ,`ConvertImage` と `DetectChart` の一時 Mat/MatExpr の解放,Info.csv の RFC 4180 クォート,セルフテストの不正な出力先で終了コード 2 を返すこと,`FastClone` の失敗時の解放. + - 動機・結果: 検出した候補 40 件超のうち,自動修正できたのは 7 件だけだった.要対応 26 件(ローカルの報告台帳 `.claude/auto-audit-report.md`)の多くは「`CameraBase`・`Form1`・カメラ層で再現テストを書けない」ことが理由で,これを受けてテスト方針を 4 層モデルに改訂した(PLAN_05). + - 注意: 追加したリーク再現テストのうち 2 件は環境に依存しうる(OpenCV 内部の参照カウントのオフセット読み取り,プライベートメモリ増分のしきい値判定).OpenCvSharp の更新時や別マシンで落ちたら見直す(PLAN_05 で `[Category("Memory")]` に分ける予定). + - 後段: 要対応のうち重大度「中」(保存失敗が成功扱い・終了時のハングや画像の取りこぼし・校正例外がコールバックへ素通り・起動時の設定エラー・光源ポート使用中)は PLAN_05 のフェーズ 1〜3 で対応する. +