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 で対応する.
+