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",