From 0b2717ab7549eb6de7b3584a89604da2db57763f Mon Sep 17 00:00:00 2001
From: perditavojo <117562794+perditavojo@users.noreply.github.com>
Date: Wed, 27 May 2026 10:53:33 +0800
Subject: [PATCH 1/2] =?UTF-8?q?fix(logging):=20=E8=AA=BF=E6=95=B4=E6=AD=A3?=
=?UTF-8?q?=E5=BC=8F=E7=89=88=E6=97=A5=E8=AA=8C=E9=96=80=E6=AA=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Release 預設只寫入 Warning 以上,Debug 與測試主機保留 Info 診斷。
同步調整 GameInput、震動與單實例診斷等級,並補上 LoggerService 與 GameInput 日誌邊界測試。
---
docs/engineering/core-engineering.md | 2 +
docs/engineering/gamepad-api.md | 1 +
.../Core/Input/GameInputGamepadController.cs | 58 +++---
.../Core/Input/XInputGamepadController.cs | 27 +--
src/InputBox/Core/Services/FeedbackService.cs | 3 +-
src/InputBox/Core/Services/LoggerService.cs | 131 ++++++++++++-
src/InputBox/Program.cs | 4 +-
.../GameInputDirectUsageTests.cs | 18 ++
tests/InputBox.Tests/LoggerServiceTests.cs | 175 +++++++++++++++++-
tests/InputBox.Tests/README.md | 6 +-
10 files changed, 374 insertions(+), 51 deletions(-)
diff --git a/docs/engineering/core-engineering.md b/docs/engineering/core-engineering.md
index 206092c..d3cb46f 100644
--- a/docs/engineering/core-engineering.md
+++ b/docs/engineering/core-engineering.md
@@ -31,3 +31,5 @@
- **格式與診斷紀律**:
- **EditorConfig 強制套用**:每次修改任何檔案後,必須遵循專案根目錄 `.editorconfig` 的縮排、編碼、換行與格式設定。
- **C# 診斷清零**:每次修改 `*.cs` 檔案後,提交或回覆前都必須檢查該檔案的 IDE 與 CS 類型診斷,並修正新增的建議、警告與錯誤。
+ - **Release 日誌門檻**:正式版預設只寫入 `Warning` 以上;`Info` 僅供 Debug、測試主機或 `INPUTBOX_LOG_LEVEL=Info` 臨時診斷使用。
+ - **可行動診斷原則**:正常 lifecycle、成功 probe、成功或略過震動、一般輪詢健康資料應維持 `Info`;會影響使用者操作、裝置可用性、API 呼叫失敗或資料修復失敗的訊號才可升級為 `Warning` 或 `Error`。
diff --git a/docs/engineering/gamepad-api.md b/docs/engineering/gamepad-api.md
index 96b8538..3397fc5 100644
--- a/docs/engineering/gamepad-api.md
+++ b/docs/engineering/gamepad-api.md
@@ -24,6 +24,7 @@
- **GameInput 發佈驗證**:CI 與 release 不再建置舊版 `InputBox.GameInput.Native` 原生專案;發佈輸出與 ZIP 必須確認不包含 `InputBox.GameInput.Native.dll` 或 `gameinput.dll` sidecar。GameInput runtime 不可用時,仍必須走既有初始化失敗並退避 XInput 路徑。
- **GameInput 手動硬體驗證**:自動驗證通過後,若正式發佈或變更內容涉及 GameInput 硬體行為,仍應依 `docs/engineering/gameinput-hardware-verification.md` 抽測實體控制器。此矩陣不是 CI 關卡,也不是每個 PR 的必跑項目。
- **GameInput runtime probe**:必須保留 InputWeave 的 runtime probe 診斷,回報 loader policy、HRESULT / Win32 error、候選與實際選取的 module kind/path/version;此資訊只供 log 與測試,不可影響按鍵語意。
+- **GameInput Release 日誌邊界**:正常 `GameInputRuntime/InputWeave`、`GameInputDiag reason=init/stop-polling/dispose` 與 repeated timestamp 計數只屬於 `Info` 診斷;只有 runtime 不可用、callback 註冊失敗、missing reading 門檻、裝置狀態非 connected、讀取例外或震動 API 失敗才可在 Release 預設門檻下輸出。
- **GameInput DLL 載入安全**:GameInput runtime 載入策略由 InputWeave 負責,InputBox 不得自行從工作目錄、目前目錄或未限定搜尋路徑載入 `gameinput.dll`。
- **GameInput 同步**:InputBox 仍必須在背景 MTA polling thread 建立、存取與釋放 InputWeave `GameInputClient` / `GameInputDevice` / callback registration;callback 只能設定喚醒或重新整理訊號,不得直接觸發 managed UI 命令。
- **GameInput 診斷 metadata 邊界**:runtime probe、timestamp stale counters、missing reading counters、device unavailable refresh counters 只能進入 log、測試或未來診斷快照,不可直接改變 edge detection、Pause/Resume neutral gate 或任何 UI 命令。
diff --git a/src/InputBox/Core/Input/GameInputGamepadController.cs b/src/InputBox/Core/Input/GameInputGamepadController.cs
index 3701043..352cf11 100644
--- a/src/InputBox/Core/Input/GameInputGamepadController.cs
+++ b/src/InputBox/Core/Input/GameInputGamepadController.cs
@@ -861,7 +861,7 @@ private void SetupReadingCallback()
}
catch (Exception ex)
{
- LoggerService.LogException(ex, "GameInput 註冊 ReadingCallback 失敗");
+ LoggerService.LogWarning($"GameInput 註冊 ReadingCallback 失敗 exception={ex.GetType().Name} message={ex.Message}");
Debug.WriteLine($"GameInput 註冊 ReadingCallback 失敗:{ex.Message}");
}
@@ -940,8 +940,17 @@ private async Task PollingLoopAsync(CancellationToken cancellationToken, TaskCom
probeInfo.Candidates.Select(candidate =>
$"{candidate.ModuleKind}:exists={candidate.Exists}:loadHr=0x{unchecked((uint)candidate.LoadHResult):X8}:procHr=0x{unchecked((uint)candidate.GetProcAddressHResult):X8}:win32={candidate.Win32Error}:path={candidate.ModulePath}"));
- LoggerService.LogInfo(
- $"GameInputProbe/InputWeave available={probeInfo.IsAvailable} hr=0x{unchecked((uint)probeInfo.HResult):X8} win32={probeInfo.Win32Error} selectedKind={probeInfo.SelectedModuleKind} selectedPath={probeInfo.SelectedModulePath} selectedVersion={probeInfo.SelectedFileVersion} candidates={candidateSummary}");
+ string probeMessage =
+ $"GameInputProbe/InputWeave available={probeInfo.IsAvailable} hr=0x{unchecked((uint)probeInfo.HResult):X8} win32={probeInfo.Win32Error} selectedKind={probeInfo.SelectedModuleKind} selectedPath={probeInfo.SelectedModulePath} selectedVersion={probeInfo.SelectedFileVersion} candidates={candidateSummary}";
+
+ if (probeInfo.IsAvailable)
+ {
+ LoggerService.LogInfo(probeMessage);
+ }
+ else
+ {
+ LoggerService.LogWarning(probeMessage);
+ }
}
Debug.WriteLine($"GameInput 在背景執行緒初始化失敗:{ex.Message}");
@@ -1053,8 +1062,17 @@ private void LogGameInputDiagnosticsSnapshot(string reason)
lastReadDeviceStatus = _lastReadDeviceStatus;
}
- LoggerService.LogInfo(
- $"GameInputDiag reason={reason} missingReadings={missingReadingCount} repeatedTimestamps={repeatedTimestampCount} backwardTimestamps={backwardTimestampCount} deviceUnavailableRefreshes={deviceUnavailableRefreshCount} lastTimestamp={lastReadingTimestamp} lastReadHr=0x{unchecked((uint)lastReadHResult):X8} lastDeviceStatus=0x{lastReadDeviceStatus:X8}");
+ string message =
+ $"GameInputDiag reason={reason} missingReadings={missingReadingCount} repeatedTimestamps={repeatedTimestampCount} backwardTimestamps={backwardTimestampCount} deviceUnavailableRefreshes={deviceUnavailableRefreshCount} lastTimestamp={lastReadingTimestamp} lastReadHr=0x{unchecked((uint)lastReadHResult):X8} lastDeviceStatus=0x{lastReadDeviceStatus:X8}";
+
+ if (ShouldWarnGameInputDiagnostics(reason))
+ {
+ LoggerService.LogWarning(message);
+ }
+ else
+ {
+ LoggerService.LogInfo(message);
+ }
}
catch (Exception ex)
{
@@ -1062,6 +1080,15 @@ private void LogGameInputDiagnosticsSnapshot(string reason)
}
}
+ ///
+ /// 判斷 GameInput 診斷原因是否應進入 Release 預設日誌。
+ ///
+ /// 診斷觸發原因。
+ /// 若應記為 Warning 則為 true。
+ private static bool ShouldWarnGameInputDiagnostics(string reason)
+ => string.Equals(reason, "missing-reading-threshold", StringComparison.Ordinal) ||
+ string.Equals(reason, "device-status-non-connected", StringComparison.Ordinal);
+
///
/// 安全呼叫 InputWeave runtime probe。
///
@@ -2908,23 +2935,14 @@ public Task VibrateAsync(
}
catch (Exception innerEx)
{
-#if DEBUG
- LoggerService.LogInfo($"VibrationDiag source=GameInput stage=api action=stop outcome=failed reason=cancel-stop exception={innerEx.GetType().Name} message={innerEx.Message}");
-#endif
+ LoggerService.LogWarning($"VibrationDiag source=GameInput stage=api action=stop outcome=failed reason=cancel-stop exception={innerEx.GetType().Name} message={innerEx.Message}");
Debug.WriteLine($"[GameInput] 取消後強制停止馬達失敗(已忽略):{innerEx.Message}");
}
}
-#if DEBUG
catch (Exception ex)
{
- LoggerService.LogInfo($"VibrationDiag source=GameInput stage=api action=start outcome=failed exception={ex.GetType().Name} message={ex.Message}");
- }
-#else
- catch (Exception)
- {
-
+ LoggerService.LogWarning($"VibrationDiag source=GameInput stage=api action=start outcome=failed exception={ex.GetType().Name} message={ex.Message}");
}
-#endif
},
ct);
}
@@ -3122,9 +3140,7 @@ public void StopVibration()
}
catch (Exception bgEx)
{
-#if DEBUG
- LoggerService.LogInfo($"VibrationDiag source=GameInput stage=api action=stop outcome=failed reason=sync-stop-bg exception={bgEx.GetType().Name} message={bgEx.Message}");
-#endif
+ LoggerService.LogWarning($"VibrationDiag source=GameInput stage=api action=stop outcome=failed reason=sync-stop-bg exception={bgEx.GetType().Name} message={bgEx.Message}");
Debug.WriteLine($"[GameInput] 背景停止馬達失敗(已忽略):{bgEx.Message}");
}
});
@@ -3137,9 +3153,7 @@ public void StopVibration()
}
catch (Exception ex)
{
-#if DEBUG
- LoggerService.LogInfo($"VibrationDiag source=GameInput stage=api action=stop outcome=failed reason=sync-stop exception={ex.GetType().Name} message={ex.Message}");
-#endif
+ LoggerService.LogWarning($"VibrationDiag source=GameInput stage=api action=stop outcome=failed reason=sync-stop exception={ex.GetType().Name} message={ex.Message}");
Debug.WriteLine($"GameInput 停止震動失敗:{ex.Message}");
}
}
diff --git a/src/InputBox/Core/Input/XInputGamepadController.cs b/src/InputBox/Core/Input/XInputGamepadController.cs
index 5d7d2d1..c8091b9 100644
--- a/src/InputBox/Core/Input/XInputGamepadController.cs
+++ b/src/InputBox/Core/Input/XInputGamepadController.cs
@@ -1831,19 +1831,15 @@ public void StopVibration()
uint stopResult = XInput.XInputSetState(_userIndex, in stopVibration);
-#if DEBUG
if (stopResult != 0)
{
- LoggerService.LogInfo(
+ LoggerService.LogWarning(
$"VibrationDiag source=XInput stage=api action=stop outcome=failed reason=sync-stop result={stopResult} userIndex={_userIndex}");
}
-#endif
}
catch (Exception ex)
{
-#if DEBUG
- LoggerService.LogInfo($"VibrationDiag source=XInput stage=api action=stop outcome=failed reason=sync-stop exception={ex.GetType().Name} message={ex.Message}");
-#endif
+ LoggerService.LogWarning($"VibrationDiag source=XInput stage=api action=stop outcome=failed reason=sync-stop exception={ex.GetType().Name} message={ex.Message}");
Debug.WriteLine($"[XInput] StopVibration 失敗(已忽略):{ex.Message}");
}
}
@@ -1961,15 +1957,15 @@ public Task VibrateAsync(
uint startResult = XInput.XInputSetState(userIndex, in vibration);
-#if DEBUG
if (startResult != 0)
{
- LoggerService.LogInfo(
+ LoggerService.LogWarning(
$"VibrationDiag source=XInput stage=api action=start outcome=failed result={startResult} userIndex={userIndex} strength={safeStrength} durationMs={safeDurationMs} priority={priority}");
return;
}
+#if DEBUG
bool shouldLogApiSuccess = priority != VibrationPriority.Ambient ||
Interlocked.Increment(ref _vibrationDiagSampleCounter) % 20 == 0;
@@ -1978,11 +1974,6 @@ public Task VibrateAsync(
LoggerService.LogInfo(
$"VibrationDiag source=XInput stage=api action=start outcome=ok result={startResult} userIndex={userIndex} strength={safeStrength} durationMs={safeDurationMs} priority={priority}");
}
-#else
- if (startResult != 0)
- {
- return;
- }
#endif
using CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(token, ct);
@@ -1998,13 +1989,11 @@ public Task VibrateAsync(
XInput.XInputVibration stop = default;
uint stopResult = XInput.XInputSetState(userIndex, in stop);
-#if DEBUG
if (stopResult != 0)
{
- LoggerService.LogInfo(
+ LoggerService.LogWarning(
$"VibrationDiag source=XInput stage=api action=stop outcome=failed reason=external-cancel result={stopResult} userIndex={userIndex}");
}
-#endif
}
return;
@@ -2019,13 +2008,11 @@ public Task VibrateAsync(
XInput.XInputVibration stopVibration = default;
uint stopFinalResult = XInput.XInputSetState(userIndex, in stopVibration);
-#if DEBUG
if (stopFinalResult != 0)
{
- LoggerService.LogInfo(
+ LoggerService.LogWarning(
$"VibrationDiag source=XInput stage=api action=stop outcome=failed reason=normal-finish result={stopFinalResult} userIndex={userIndex}");
}
-#endif
}, ct);
}
@@ -2292,4 +2279,4 @@ private void ClearAllEvents()
RightTriggerRepeat = null;
ConnectionChanged = null;
}
-}
\ No newline at end of file
+}
diff --git a/src/InputBox/Core/Services/FeedbackService.cs b/src/InputBox/Core/Services/FeedbackService.cs
index f48fba8..1a3aeb7 100644
--- a/src/InputBox/Core/Services/FeedbackService.cs
+++ b/src/InputBox/Core/Services/FeedbackService.cs
@@ -179,6 +179,7 @@ public static async Task VibrateAsync(
}
catch (Exception ex)
{
+ LoggerService.LogWarning($"VibrationDiag source=FeedbackService stage=dispatch outcome=failed controller={controller.GetType().Name} priority={priority} exception={ex.GetType().Name} message={ex.Message}");
Debug.WriteLine($"[震動] 控制器震動失敗(已忽略):{ex.Message}");
}
}
@@ -352,4 +353,4 @@ public static void EmergencyStopAllActiveControllers()
Debug.WriteLine($"緊急清理發生錯誤,已忽略:{ex.Message}");
}
}
-}
\ No newline at end of file
+}
diff --git a/src/InputBox/Core/Services/LoggerService.cs b/src/InputBox/Core/Services/LoggerService.cs
index 57a4d73..b56fd16 100644
--- a/src/InputBox/Core/Services/LoggerService.cs
+++ b/src/InputBox/Core/Services/LoggerService.cs
@@ -13,6 +13,27 @@ namespace InputBox.Core.Services;
///
internal static class LoggerService
{
+ ///
+ /// 可寫入檔案的日誌等級。
+ ///
+ internal enum LogLevel
+ {
+ ///
+ /// 一般診斷資訊。
+ ///
+ Info = 0,
+
+ ///
+ /// 使用者或維護者需要注意的非致命異常。
+ ///
+ Warning = 1,
+
+ ///
+ /// 失敗或例外。
+ ///
+ Error = 2
+ }
+
///
/// 記錄鎖定物件(使用 C# 13 System.Threading.Lock)
///
@@ -51,6 +72,11 @@ internal static class LoggerService
///
private const int MaxBackupFiles = 5;
+ ///
+ /// 日誌等級覆寫環境變數名稱。
+ ///
+ private const string LogLevelEnvironmentVariableName = "INPUTBOX_LOG_LEVEL";
+
///
/// 記錄例外錯誤
///
@@ -63,6 +89,11 @@ public static void LogException(Exception? ex, string context = "")
return;
}
+ if (!ShouldWrite(LogLevel.Error))
+ {
+ return;
+ }
+
StringBuilder sb = new();
sb.AppendLine("================================================================");
@@ -102,17 +133,113 @@ public static void LogException(Exception? ex, string context = "")
///
/// 訊息內容
public static void LogInfo(string message)
+ => LogMessage(LogLevel.Info, "INFO", message);
+
+ ///
+ /// 記錄警告。
+ ///
+ /// 訊息內容。
+ public static void LogWarning(string message)
+ => LogMessage(LogLevel.Warning, "WARNING", message);
+
+ ///
+ /// 記錄錯誤。
+ ///
+ /// 訊息內容。
+ public static void LogError(string message)
+ => LogMessage(LogLevel.Error, "ERROR", message);
+
+ ///
+ /// 記錄單行訊息。
+ ///
+ /// 日誌等級。
+ /// 輸出等級名稱。
+ /// 訊息內容。
+ private static void LogMessage(LogLevel level, string levelName, string message)
{
if (string.IsNullOrWhiteSpace(message))
{
return;
}
- string logEntry = $"[INFO] {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff zzz} - {message}{Environment.NewLine}";
+ if (!ShouldWrite(level))
+ {
+ return;
+ }
+
+ string logEntry = $"[{levelName}] {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff zzz} - {message}{Environment.NewLine}";
WriteToFile(logEntry);
}
+ ///
+ /// 判斷目前等級是否應寫入。
+ ///
+ /// 欲寫入的日誌等級。
+ /// 若應寫入則為 true。
+ private static bool ShouldWrite(LogLevel level)
+ => level >= GetMinimumLogLevel();
+
+ ///
+ /// 取得目前有效的最低日誌等級。
+ ///
+ /// 最低寫入等級。
+ private static LogLevel GetMinimumLogLevel()
+ => ResolveMinimumLogLevel(
+ IsRunningUnderTestHost(),
+ IsDebugBuild(),
+ Environment.GetEnvironmentVariable(LogLevelEnvironmentVariableName));
+
+ ///
+ /// 供測試驗證門檻解析邏輯。
+ ///
+ /// 是否為測試主機。
+ /// 是否為 Debug 建置。
+ /// 環境變數覆寫值。
+ /// 解析後最低寫入等級。
+ internal static LogLevel ResolveMinimumLogLevelForTests(
+ bool isRunningUnderTestHost,
+ bool isDebugBuild,
+ string? overrideValue)
+ => ResolveMinimumLogLevel(isRunningUnderTestHost, isDebugBuild, overrideValue);
+
+ ///
+ /// 解析最低日誌等級。
+ ///
+ /// 是否為測試主機。
+ /// 是否為 Debug 建置。
+ /// 環境變數覆寫值。
+ /// 最低寫入等級。
+ private static LogLevel ResolveMinimumLogLevel(
+ bool isRunningUnderTestHost,
+ bool isDebugBuild,
+ string? overrideValue)
+ {
+ if (!string.IsNullOrWhiteSpace(overrideValue) &&
+ Enum.TryParse(overrideValue.Trim(), ignoreCase: true, out LogLevel overrideLevel) &&
+ Enum.IsDefined(typeof(LogLevel), overrideLevel))
+ {
+ return overrideLevel;
+ }
+
+ return isRunningUnderTestHost || isDebugBuild ?
+ LogLevel.Info :
+ LogLevel.Warning;
+ }
+
+ ///
+ /// 判斷目前是否為 Debug 建置。
+ ///
+ /// 若為 Debug 建置則為 true。
+ private static bool IsDebugBuild()
+ {
+#if DEBUG
+ return true;
+#else
+ return false;
+#endif
+ }
+
///
/// 執行實體寫入動作(執行緒安全)
///
@@ -267,4 +394,4 @@ private static void RotateLogs(string logFileName)
Debug.WriteLine($"日誌輪替失敗:{ex.Message}");
}
}
-}
\ No newline at end of file
+}
diff --git a/src/InputBox/Program.cs b/src/InputBox/Program.cs
index 475bf86..6957283 100644
--- a/src/InputBox/Program.cs
+++ b/src/InputBox/Program.cs
@@ -105,7 +105,7 @@ static void Main()
// 則不允許 fallback 啟動新視窗,避免破壞單實例預期。
if (!fallbackPermitted)
{
- LoggerService.LogInfo($"SingleInstance.FallbackSuppressed pid={Environment.ProcessId} reason=foreground_blocked");
+ LoggerService.LogWarning($"SingleInstance.FallbackSuppressed pid={Environment.ProcessId} reason=foreground_blocked detail={activationDiagnostic}");
return;
}
@@ -591,4 +591,4 @@ static void HandleException(Exception? ex)
// 發生嚴重錯誤後強制結束進程。
Environment.Exit(1);
}
-}
\ No newline at end of file
+}
diff --git a/tests/InputBox.Tests/GameInputDirectUsageTests.cs b/tests/InputBox.Tests/GameInputDirectUsageTests.cs
index 77330a2..2d66f82 100644
--- a/tests/InputBox.Tests/GameInputDirectUsageTests.cs
+++ b/tests/InputBox.Tests/GameInputDirectUsageTests.cs
@@ -134,6 +134,24 @@ public void HasSameInputValues_ButtonChanged_ReturnsFalse()
Assert.False((bool)method.Invoke(null, [current, previous])!);
}
+ ///
+ /// GameInput lifecycle 診斷應維持 Info,只有可行動的讀取或裝置狀態異常才升級為 Warning。
+ ///
+ [Fact]
+ public void ShouldWarnGameInputDiagnostics_OnlyActionableReasons_ReturnTrue()
+ {
+ MethodInfo method = typeof(GameInputGamepadController).GetMethod(
+ "ShouldWarnGameInputDiagnostics",
+ BindingFlags.Static | BindingFlags.NonPublic)
+ ?? throw new InvalidOperationException("找不到 GameInputGamepadController.ShouldWarnGameInputDiagnostics。");
+
+ Assert.False((bool)method.Invoke(null, ["init"])!);
+ Assert.False((bool)method.Invoke(null, ["stop-polling"])!);
+ Assert.False((bool)method.Invoke(null, ["dispose"])!);
+ Assert.True((bool)method.Invoke(null, ["missing-reading-threshold"])!);
+ Assert.True((bool)method.Invoke(null, ["device-status-non-connected"])!);
+ }
+
///
/// InputBox 的穩定裝置識別 helper 應優先使用 PnP path,缺失時退回 VID/PID 與顯示名稱。
///
diff --git a/tests/InputBox.Tests/LoggerServiceTests.cs b/tests/InputBox.Tests/LoggerServiceTests.cs
index 2abf694..3eed717 100644
--- a/tests/InputBox.Tests/LoggerServiceTests.cs
+++ b/tests/InputBox.Tests/LoggerServiceTests.cs
@@ -26,6 +26,10 @@ public sealed class LoggerServiceTestCollection;
[Collection(LoggerServiceTestRequirements.CollectionName)]
public sealed class LoggerServiceTests : IDisposable
{
+ private readonly List _temporaryLogPaths = [];
+ private readonly string? _originalLogFileName;
+ private readonly string? _originalLogLevel;
+
///
/// 正式執行時的主要日誌檔路徑,用於驗證測試不會污染使用者平常檢視的記錄。
///
@@ -51,6 +55,12 @@ public sealed class LoggerServiceTests : IDisposable
///
public LoggerServiceTests()
{
+ _originalLogFileName = Environment.GetEnvironmentVariable("INPUTBOX_LOG_FILE_NAME");
+ _originalLogLevel = Environment.GetEnvironmentVariable("INPUTBOX_LOG_LEVEL");
+
+ Environment.SetEnvironmentVariable("INPUTBOX_LOG_FILE_NAME", null);
+ Environment.SetEnvironmentVariable("INPUTBOX_LOG_LEVEL", null);
+
Directory.CreateDirectory(LoggerService.LogDirectory);
BackupIfExists(MainLogPath, MainLogBackupPath);
@@ -62,6 +72,14 @@ public LoggerServiceTests()
///
public void Dispose()
{
+ Environment.SetEnvironmentVariable("INPUTBOX_LOG_FILE_NAME", _originalLogFileName);
+ Environment.SetEnvironmentVariable("INPUTBOX_LOG_LEVEL", _originalLogLevel);
+
+ foreach (string temporaryLogPath in _temporaryLogPaths)
+ {
+ DeleteIfExists(temporaryLogPath);
+ }
+
RestoreOrDelete(MainLogPath, MainLogBackupPath);
RestoreOrDelete(TestLogPath, TestLogBackupPath);
}
@@ -95,6 +113,132 @@ public void LogException_UnderTestHost_WritesToDedicatedTestLogFile()
Assert.Contains("LoggerServiceTests", content);
}
+ ///
+ /// Release 且非測試主機時,預設日誌門檻應為 Warning,避免一般診斷污染正式日誌。
+ ///
+ [Fact]
+ public void ResolveMinimumLogLevel_ReleaseNonTestHost_ReturnsWarning()
+ {
+ Assert.Equal(
+ LoggerService.LogLevel.Warning,
+ LoggerService.ResolveMinimumLogLevelForTests(
+ isRunningUnderTestHost: false,
+ isDebugBuild: false,
+ overrideValue: null));
+ }
+
+ ///
+ /// Debug 或測試主機應保留 Info 診斷,方便開發與回歸測試追蹤問題。
+ ///
+ [Fact]
+ public void ResolveMinimumLogLevel_DebugOrTestHost_ReturnsInfo()
+ {
+ Assert.Equal(
+ LoggerService.LogLevel.Info,
+ LoggerService.ResolveMinimumLogLevelForTests(
+ isRunningUnderTestHost: false,
+ isDebugBuild: true,
+ overrideValue: null));
+ Assert.Equal(
+ LoggerService.LogLevel.Info,
+ LoggerService.ResolveMinimumLogLevelForTests(
+ isRunningUnderTestHost: true,
+ isDebugBuild: false,
+ overrideValue: null));
+ }
+
+ ///
+ /// 有效的 INPUTBOX_LOG_LEVEL 應覆寫建置組態預設門檻,方便使用者臨時開啟診斷。
+ ///
+ [Fact]
+ public void ResolveMinimumLogLevel_ValidOverride_ReturnsOverride()
+ {
+ Assert.Equal(
+ LoggerService.LogLevel.Info,
+ LoggerService.ResolveMinimumLogLevelForTests(
+ isRunningUnderTestHost: false,
+ isDebugBuild: false,
+ overrideValue: "Info"));
+ Assert.Equal(
+ LoggerService.LogLevel.Error,
+ LoggerService.ResolveMinimumLogLevelForTests(
+ isRunningUnderTestHost: true,
+ isDebugBuild: true,
+ overrideValue: "error"));
+ }
+
+ ///
+ /// 無效的 INPUTBOX_LOG_LEVEL 不應改變預設門檻,避免拼錯值導致正式日誌過度輸出。
+ ///
+ [Fact]
+ public void ResolveMinimumLogLevel_InvalidOverride_ReturnsDefault()
+ {
+ Assert.Equal(
+ LoggerService.LogLevel.Warning,
+ LoggerService.ResolveMinimumLogLevelForTests(
+ isRunningUnderTestHost: false,
+ isDebugBuild: false,
+ overrideValue: "Verbose"));
+ Assert.Equal(
+ LoggerService.LogLevel.Warning,
+ LoggerService.ResolveMinimumLogLevelForTests(
+ isRunningUnderTestHost: false,
+ isDebugBuild: false,
+ overrideValue: "3"));
+ }
+
+ ///
+ /// Warning 門檻下,Info 診斷不應寫入檔案。
+ ///
+ [Fact]
+ public void LogInfo_WhenMinimumLevelIsWarning_DoesNotWrite()
+ {
+ string logPath = UseTemporaryLogFile();
+ Environment.SetEnvironmentVariable("INPUTBOX_LOG_LEVEL", "Warning");
+
+ LoggerService.LogInfo("logger-info-marker");
+
+ Assert.False(File.Exists(logPath));
+ }
+
+ ///
+ /// Warning 門檻下,Warning、Error 與例外都應寫入檔案。
+ ///
+ [Fact]
+ public void LogWarningErrorAndException_WhenMinimumLevelIsWarning_Write()
+ {
+ string logPath = UseTemporaryLogFile();
+ Environment.SetEnvironmentVariable("INPUTBOX_LOG_LEVEL", "Warning");
+
+ LoggerService.LogWarning("logger-warning-marker");
+ LoggerService.LogError("logger-error-marker");
+ LoggerService.LogException(new InvalidOperationException("logger-exception-marker"), "LoggerServiceTests");
+
+ string content = File.ReadAllText(logPath);
+ Assert.Contains("[WARNING]", content);
+ Assert.Contains("logger-warning-marker", content);
+ Assert.Contains("[ERROR]", content);
+ Assert.Contains("logger-error-marker", content);
+ Assert.Contains("[EXCEPTION]", content);
+ Assert.Contains("logger-exception-marker", content);
+ }
+
+ ///
+ /// 使用 INPUTBOX_LOG_LEVEL=Info 時,Info 診斷應可臨時寫入檔案。
+ ///
+ [Fact]
+ public void LogInfo_WhenEnvironmentOverrideIsInfo_Writes()
+ {
+ string logPath = UseTemporaryLogFile();
+ Environment.SetEnvironmentVariable("INPUTBOX_LOG_LEVEL", "Info");
+
+ LoggerService.LogInfo("logger-info-override-marker");
+
+ string content = File.ReadAllText(logPath);
+ Assert.Contains("[INFO]", content);
+ Assert.Contains("logger-info-override-marker", content);
+ }
+
///
/// 若目標檔案存在則先備份到測試專用路徑。
///
@@ -116,6 +260,35 @@ private static void BackupIfExists(string sourcePath, string backupPath)
});
}
+ ///
+ /// 刪除檔案(若存在)。
+ ///
+ /// 檔案路徑。
+ private static void DeleteIfExists(string path)
+ {
+ RetryFileOperation(() =>
+ {
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ });
+ }
+
+ ///
+ /// 使用本測試專屬暫存日誌檔。
+ ///
+ /// 暫存日誌檔完整路徑。
+ private string UseTemporaryLogFile()
+ {
+ string logFileName = $"InputBox.logger-test-{Guid.NewGuid():N}.log";
+ string logPath = Path.Combine(LoggerService.LogDirectory, logFileName);
+ _temporaryLogPaths.Add(logPath);
+ Environment.SetEnvironmentVariable("INPUTBOX_LOG_FILE_NAME", logFileName);
+
+ return logPath;
+ }
+
///
/// 若有備份則還原,否則刪除測試產生的檔案。
///
@@ -166,4 +339,4 @@ private static void RetryFileOperation(Action fileOperation)
throw lastException ?? new IOException("檔案操作在重試後仍失敗。");
}
-}
\ No newline at end of file
+}
diff --git a/tests/InputBox.Tests/README.md b/tests/InputBox.Tests/README.md
index dfae4a2..3e52f5b 100644
--- a/tests/InputBox.Tests/README.md
+++ b/tests/InputBox.Tests/README.md
@@ -22,7 +22,7 @@
| `GamepadDeadzoneHysteresisTests` | `GamepadDeadzoneHysteresis.ResolveDirection`(int / float 多載),含正負方向對稱性守門(硬體平等原則) | 14 |
| `GamepadControllerFactoryTests` | 控制器後端建立策略,驗證 XInput 預設路徑、GameInput 成功路徑,以及 GameInput 執行階段不可用時退避至 XInput | 3 |
| `GamepadControllerPauseTests` | 控制器在 `Pause()` / `Resume()`、連線可用性語意、GameInput 讀取缺失斷線重列舉、裝置狀態過濾、震動停止安全性、`ClearAllEvents` 肩鍵釋放訂閱清除與原生對話框切換時的殘留輸入回歸保護 | 10 |
-| `GameInputDirectUsageTests` | `GameInputGamepadController` 直接使用 `InputWeave.GameInput` 遊戲控制器介面的守門、callback function pointer marshaling、官方 v3 按鍵位元、快照邊緣偵測、穩定裝置識別與震動參數保留 | 8 |
+| `GameInputDirectUsageTests` | `GameInputGamepadController` 直接使用 `InputWeave.GameInput` 遊戲控制器介面的守門、callback function pointer marshaling、官方 v3 按鍵位元、快照邊緣偵測、Release 日誌邊界、穩定裝置識別與震動參數保留 | 9 |
| `GamepadCalibrationVisualizerMapperTests` | `GamepadCalibrationVisualizerMapper` 對校準視覺化座標限制、死區半徑換算、D-Pad 導覽防誤觸,以及雙搖桿狀態/控制器連線文案格式化的回歸保護 | 14 |
| `GamepadEventBinderTests` | `GamepadEventBinder` 的 LB / RB / LT / RT 與肩鍵放開事件綁定回歸保護 | 1 |
| `GamepadFaceButtonProfileTests` | `GamepadFaceButtonProfile` 的 Auto 解析、手動覆寫優先權、GameInput 裝置識別保留 VID/PID 時的 Sony/Nintendo 判斷,以及 Xbox / PlayStation / Nintendo 模式的按鍵標示、助記詞同步、資源化字串、主畫面說明文字、目前生效配置顯示、標題列提示、選單勾選邏輯與 PlayStation ○/× 確認模式回歸保護 | 25 |
@@ -35,7 +35,7 @@
| `GamepadMessageBoxTests` | `GamepadMessageBox` 關閉取消、生命週期資源保護與已連線控制器提示同步 | 5 |
| `InputBoxLayoutManagerTests` | `InputBoxLayoutManager` 版面管理 | 4 |
| `InputHistoryServiceTests` | `InputHistoryService` 歷程記錄 CRUD 與 5 筆翻頁導覽 | 15 |
-| `LoggerServiceTests` | `LoggerService` 測試環境專屬日誌分流與正式日誌隔離保護 | 1 |
+| `LoggerServiceTests` | `LoggerService` 測試環境專屬日誌分流、Release-like 日誌門檻、環境變數覆寫與正式日誌隔離保護 | 8 |
| `MainFormUiSmokeTests` | `MainForm` 使用 FlaUI 驗證主視窗啟動、右鍵選單主要命令、設定中的控制器子選單、控制器校準視覺化對話框、片語子選單、片語管理視窗、片語編輯視窗、HelpDialog、返回時最小化確認對話框、程式內確認重啟後主視窗保持前景,以及基本複製流程的 UI 冒煙測試 | 11 |
| `PhraseServiceTests` | `PhraseService` CRUD、匯出/匯入、併發匯出、併發暫存檔誤刪,以及持久化失敗時的記憶體復原回歸保護 | 39 |
| `RestartActivationCoordinatorTests` | `RestartActivationCoordinator` 的一次性重啟前景啟用標記、單次消費與過期清理保護 | 3 |
@@ -45,7 +45,7 @@
| `TaskExtensionsTests` | `TaskExtensions` CTS 擴充方法與生命週期連結保護 | 12 |
| `VibrationPatternsTests` | `VibrationPatterns` 與方向性震動設定、語意情境解析、能力感知的多段式微震動序列,以及歷程滾輪阻尼感、字數上限硬牆、震動強度預覽、右搖桿選取粒度、組合鍵進入提示與喚起握手回饋的回歸保護 | 37 |
| `VibrationSafetyLimiterTests` | `VibrationSafetyLimiter` 熱保護、Duty Cycle 限制器與極端邊界保護 | 8 |
-| **合計** | | **384** |
+| **合計** | | **392** |
## 二、執行方式 🚀
From 75a9d971420593d0e36d05f3627fa4f84d414078 Mon Sep 17 00:00:00 2001
From: perditavojo <117562794+perditavojo@users.noreply.github.com>
Date: Wed, 27 May 2026 12:18:54 +0800
Subject: [PATCH 2/2] =?UTF-8?q?fix(logging):=20=E5=BF=AB=E5=8F=96=E6=97=A5?=
=?UTF-8?q?=E8=AA=8C=E9=96=80=E6=AA=BB=E4=B8=8D=E8=AE=8A=E7=8B=80=E6=85=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
快取 Debug 與測試主機判斷,避免每次寫入日誌時重複掃描程序與已載入組件。
同步統一 GameInput 日誌邊界測試命名,回應 PR review。
---
src/InputBox/Core/Services/LoggerService.cs | 16 +++++++++++++---
.../InputBox.Tests/GameInputDirectUsageTests.cs | 2 +-
2 files changed, 14 insertions(+), 4 deletions(-)
diff --git a/src/InputBox/Core/Services/LoggerService.cs b/src/InputBox/Core/Services/LoggerService.cs
index b56fd16..dbf8c51 100644
--- a/src/InputBox/Core/Services/LoggerService.cs
+++ b/src/InputBox/Core/Services/LoggerService.cs
@@ -77,6 +77,16 @@ internal enum LogLevel
///
private const string LogLevelEnvironmentVariableName = "INPUTBOX_LOG_LEVEL";
+ ///
+ /// 目前建置是否為 Debug;此值在程序生命週期內不會改變。
+ ///
+ private static readonly bool _isDebugBuild = IsDebugBuild();
+
+ ///
+ /// 目前程序是否執行於測試主機;此值在程序生命週期內不會改變。
+ ///
+ private static readonly bool _isRunningUnderTestHost = IsRunningUnderTestHost();
+
///
/// 記錄例外錯誤
///
@@ -186,8 +196,8 @@ private static bool ShouldWrite(LogLevel level)
/// 最低寫入等級。
private static LogLevel GetMinimumLogLevel()
=> ResolveMinimumLogLevel(
- IsRunningUnderTestHost(),
- IsDebugBuild(),
+ _isRunningUnderTestHost,
+ _isDebugBuild,
Environment.GetEnvironmentVariable(LogLevelEnvironmentVariableName));
///
@@ -298,7 +308,7 @@ private static string GetActiveLogFileName()
return overrideLogFileName.Trim();
}
- return IsRunningUnderTestHost() ? TestLogFileName : LogFileName;
+ return _isRunningUnderTestHost ? TestLogFileName : LogFileName;
}
///
diff --git a/tests/InputBox.Tests/GameInputDirectUsageTests.cs b/tests/InputBox.Tests/GameInputDirectUsageTests.cs
index 2d66f82..ed2696f 100644
--- a/tests/InputBox.Tests/GameInputDirectUsageTests.cs
+++ b/tests/InputBox.Tests/GameInputDirectUsageTests.cs
@@ -138,7 +138,7 @@ public void HasSameInputValues_ButtonChanged_ReturnsFalse()
/// GameInput lifecycle 診斷應維持 Info,只有可行動的讀取或裝置狀態異常才升級為 Warning。
///
[Fact]
- public void ShouldWarnGameInputDiagnostics_OnlyActionableReasons_ReturnTrue()
+ public void ShouldWarnGameInputDiagnostics_ReturnsExpectedValues()
{
MethodInfo method = typeof(GameInputGamepadController).GetMethod(
"ShouldWarnGameInputDiagnostics",