清理废弃模块并完善测试基础设施
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
project(QtDesktopPetInternalQA LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_AUTOMOC OFF)
|
||||
|
||||
get_filename_component(DEFAULT_PET_SOURCE_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE)
|
||||
set(PET_SOURCE_ROOT "${DEFAULT_PET_SOURCE_ROOT}" CACHE PATH "QtDesktopPet source root")
|
||||
|
||||
if (NOT EXISTS "${PET_SOURCE_ROOT}/src" OR NOT EXISTS "${PET_SOURCE_ROOT}/CMakeLists.txt")
|
||||
message(FATAL_ERROR "PET_SOURCE_ROOT does not point to a QtDesktopPet source tree: ${PET_SOURCE_ROOT}")
|
||||
endif()
|
||||
|
||||
find_package(Qt6 REQUIRED COMPONENTS Core)
|
||||
|
||||
add_executable(QtDesktopPetCoreTests
|
||||
include/TestHarness.h
|
||||
tests/TestMain.cpp
|
||||
tests/assistant/IntentRoutingTests.cpp
|
||||
tests/reminder/ReminderTests.cpp
|
||||
tests/weather/WeatherTests.cpp
|
||||
tests/web/WebCapabilityTests.cpp
|
||||
tests/ai/ConversationStoreTests.cpp
|
||||
${PET_SOURCE_ROOT}/src/assistant/IntentRouter.h
|
||||
${PET_SOURCE_ROOT}/src/assistant/IntentRouter.cpp
|
||||
${PET_SOURCE_ROOT}/src/assistant/CommandDispatcher.h
|
||||
${PET_SOURCE_ROOT}/src/assistant/CommandDispatcher.cpp
|
||||
${PET_SOURCE_ROOT}/src/assistant/UserIntent.h
|
||||
${PET_SOURCE_ROOT}/src/reminder/ReminderParser.h
|
||||
${PET_SOURCE_ROOT}/src/reminder/ReminderParser.cpp
|
||||
${PET_SOURCE_ROOT}/src/reminder/ReminderTypes.h
|
||||
${PET_SOURCE_ROOT}/src/reminder/ReminderTypes.cpp
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherParser.h
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherParser.cpp
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherSummaryFormatter.h
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherSummaryFormatter.cpp
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherTypes.h
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherConfig.h
|
||||
${PET_SOURCE_ROOT}/src/web/WebCapabilityDetector.h
|
||||
${PET_SOURCE_ROOT}/src/web/WebCapabilityDetector.cpp
|
||||
${PET_SOURCE_ROOT}/src/web/WebChatTypes.h
|
||||
${PET_SOURCE_ROOT}/src/web/WebConfig.h
|
||||
${PET_SOURCE_ROOT}/src/config/AIConfig.h
|
||||
${PET_SOURCE_ROOT}/src/config/AIConfig.cpp
|
||||
${PET_SOURCE_ROOT}/src/ai/ConversationStore.h
|
||||
${PET_SOURCE_ROOT}/src/ai/ConversationStore.cpp
|
||||
${PET_SOURCE_ROOT}/src/ai/LLMTypes.h
|
||||
${PET_SOURCE_ROOT}/src/util/Logger.h
|
||||
${PET_SOURCE_ROOT}/src/util/Logger.cpp
|
||||
)
|
||||
|
||||
target_include_directories(QtDesktopPetCoreTests
|
||||
PRIVATE
|
||||
"${CMAKE_CURRENT_LIST_DIR}/include"
|
||||
"${PET_SOURCE_ROOT}"
|
||||
)
|
||||
|
||||
target_link_libraries(QtDesktopPetCoreTests PRIVATE Qt6::Core)
|
||||
|
||||
include(CTest)
|
||||
add_test(NAME QtDesktopPetCoreTests COMMAND QtDesktopPetCoreTests)
|
||||
@@ -0,0 +1,61 @@
|
||||
# QtDesktopPet Internal QA
|
||||
|
||||
This directory contains development-only quality assurance assets. It is tracked
|
||||
by the development repository and excluded from the public GitHub export and
|
||||
release packages.
|
||||
|
||||
## Structure
|
||||
|
||||
- `tests/`: deterministic C++ tests grouped by production module.
|
||||
- `include/`: the lightweight test harness. It does not require `Q_OBJECT`.
|
||||
- `fixtures/`: sanitized, versioned test data.
|
||||
- `config/`: extensible static-check and performance threshold configuration.
|
||||
- `scripts/`: static checks, performance sampling, and report generation.
|
||||
- `out/`: local generated output. This directory is ignored by Git.
|
||||
|
||||
## Core Logic Tests
|
||||
|
||||
The QA project is intentionally independent from the production CMake project.
|
||||
Configure it from this directory when a matching Qt toolchain is available:
|
||||
|
||||
```powershell
|
||||
cmake -S internal/qa -B build/internal-qa `
|
||||
-DCMAKE_PREFIX_PATH=D:/Qt/6.5.3/mingw_64
|
||||
cmake --build build/internal-qa
|
||||
ctest --test-dir build/internal-qa --output-on-failure
|
||||
```
|
||||
|
||||
The test process enables Qt test mode and uses temporary directories for file
|
||||
fixtures. It must not read or modify the normal QtDesktopPet user configuration.
|
||||
|
||||
## Static Checks
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass `
|
||||
-File internal/qa/scripts/Invoke-StaticChecks.ps1
|
||||
```
|
||||
|
||||
The script returns a non-zero exit code when a required invariant fails. Extend
|
||||
`config/static-checks.json` when modules or public-export boundaries change.
|
||||
|
||||
## Performance
|
||||
|
||||
Start QtDesktopPet, then sample a named scenario:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass `
|
||||
-File internal/qa/scripts/perf_sample.ps1 `
|
||||
-Scenario idle-5m -DurationSeconds 300
|
||||
```
|
||||
|
||||
Generate a Markdown summary from the resulting CSV:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass `
|
||||
-File internal/qa/scripts/New-PerformanceReport.ps1 `
|
||||
-InputPath reports/perf/<sample>.csv `
|
||||
-Scenario idle-5m
|
||||
```
|
||||
|
||||
Raw CSV files stay under `reports/perf/` and are not committed. Reviewed,
|
||||
sanitized summaries belong in `docs/test-records/`.
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"defaults": {
|
||||
"allowUnexpectedExit": false,
|
||||
"maxUnresponsiveSamples": 0,
|
||||
"maxAverageCpuPercent": null,
|
||||
"maxP95CpuPercent": null,
|
||||
"maxPrivateMemoryGrowthMB": null,
|
||||
"maxWorkingSetGrowthMB": null,
|
||||
"maxHandleGrowth": null,
|
||||
"maxThreadGrowth": null
|
||||
},
|
||||
"scenarios": {
|
||||
"idle-5m": {},
|
||||
"idle-10m": {},
|
||||
"hidden-5m": {},
|
||||
"ai-20-turns": {},
|
||||
"reminder-trigger": {},
|
||||
"weather-queries": {},
|
||||
"web-queries": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"productionSourceRoots": [
|
||||
"src"
|
||||
],
|
||||
"productionExtensions": [
|
||||
".h",
|
||||
".cpp"
|
||||
],
|
||||
"productionCmakeFile": "CMakeLists.txt",
|
||||
"qaCmakeFile": "internal/qa/CMakeLists.txt",
|
||||
"qaTestRoots": [
|
||||
"internal/qa/tests"
|
||||
],
|
||||
"activeScanPaths": [
|
||||
"CMakeLists.txt",
|
||||
"main.cpp",
|
||||
"src"
|
||||
],
|
||||
"forbiddenReferences": [
|
||||
{
|
||||
"name": "removed module paths",
|
||||
"pattern": "src[\\\\/](?:fileops|launcher|workspace|search)[\\\\/]"
|
||||
},
|
||||
{
|
||||
"name": "removed module types",
|
||||
"pattern": "\\b(?:FileOperationManager|FileSandbox|AppLaunchManager|AppDiscovery|WorkspaceController|WorkspaceAgent|WorkspaceAgentWindow|WorkspacePanel|GlobalHotkeyManager|WebSearchManager|SearchAnswerSynthesizer)\\b"
|
||||
},
|
||||
{
|
||||
"name": "removed intent actions",
|
||||
"pattern": "\\b(?:UserIntentType|CommandDispatchAction)::(?:FileOperation|LaunchApp|Workspace|Search|UnsupportedTool)\\b"
|
||||
}
|
||||
],
|
||||
"forbidQObjectMacro": true,
|
||||
"requiredPaths": [
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"installer/QtDesktopPet.iss",
|
||||
"resources/characters/shiroko/character.json",
|
||||
"resources/characters/shiroko/preview.png",
|
||||
"resources/icons/app_icon.ico",
|
||||
"resources/sounds/reminders/reminder_default.wav",
|
||||
"tools/package_release.ps1",
|
||||
"tools/prepare_github_export.ps1"
|
||||
],
|
||||
"publicExportExcludedRoots": [
|
||||
"docs",
|
||||
"internal",
|
||||
"reports"
|
||||
],
|
||||
"releaseIsolationFiles": [
|
||||
"CMakeLists.txt",
|
||||
"tools/package_release.ps1"
|
||||
],
|
||||
"releaseForbiddenPatterns": [
|
||||
"internal/qa",
|
||||
"internal\\\\qa"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
"Scenario","SampleIndex","TimestampUtc","Pid","CpuPercent","WorkingSetMB","PrivateMemoryMB","HandleCount","ThreadCount","Responding","Path","Status"
|
||||
"fixture-stable","0","2026-07-17T00:00:00.0000000Z","1000","0","100.0","80.0","200","12","True","C:\\Fixture\\QtDesktopPet.exe","running"
|
||||
"fixture-stable","1","2026-07-17T00:00:05.0000000Z","1000","1.5","101.0","80.5","201","12","True","C:\\Fixture\\QtDesktopPet.exe","running"
|
||||
"fixture-stable","2","2026-07-17T00:00:10.0000000Z","1000","2.5","102.0","81.0","202","13","True","C:\\Fixture\\QtDesktopPet.exe","running"
|
||||
|
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace Qa
|
||||
{
|
||||
struct TestCase
|
||||
{
|
||||
std::string name;
|
||||
std::function<void()> function;
|
||||
};
|
||||
|
||||
inline std::vector<TestCase> ®istry()
|
||||
{
|
||||
static std::vector<TestCase> tests;
|
||||
return tests;
|
||||
}
|
||||
|
||||
class Registrar
|
||||
{
|
||||
public:
|
||||
Registrar(const char *name, std::function<void()> function)
|
||||
{
|
||||
registry().push_back({name, std::move(function)});
|
||||
}
|
||||
};
|
||||
|
||||
[[noreturn]] inline void fail(const char *expression, const char *file, int line)
|
||||
{
|
||||
std::ostringstream stream;
|
||||
stream << file << ':' << line << ": expectation failed: " << expression;
|
||||
throw std::runtime_error(stream.str());
|
||||
}
|
||||
}
|
||||
|
||||
#define QA_JOIN_IMPL(left, right) left##right
|
||||
#define QA_JOIN(left, right) QA_JOIN_IMPL(left, right)
|
||||
|
||||
#define QA_TEST(suiteName, testName) \
|
||||
static void QA_JOIN(qa_test_function_, __LINE__)(); \
|
||||
static Qa::Registrar QA_JOIN(qa_test_registrar_, __LINE__)( \
|
||||
#suiteName "." #testName, QA_JOIN(qa_test_function_, __LINE__)); \
|
||||
static void QA_JOIN(qa_test_function_, __LINE__)()
|
||||
|
||||
#define QA_EXPECT(expression) \
|
||||
do \
|
||||
{ \
|
||||
if (!(expression)) \
|
||||
{ \
|
||||
Qa::fail(#expression, __FILE__, __LINE__); \
|
||||
} \
|
||||
} while (false)
|
||||
|
||||
#define QA_EXPECT_EQ(actual, expected) \
|
||||
do \
|
||||
{ \
|
||||
const auto qaActualValue = (actual); \
|
||||
const auto qaExpectedValue = (expected); \
|
||||
if (!(qaActualValue == qaExpectedValue)) \
|
||||
{ \
|
||||
Qa::fail(#actual " == " #expected, __FILE__, __LINE__); \
|
||||
} \
|
||||
} while (false)
|
||||
@@ -0,0 +1,321 @@
|
||||
param(
|
||||
[string]$RepoRoot = "",
|
||||
[string]$ConfigPath = "",
|
||||
[string]$ReportPath = ""
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Resolve-AbsolutePath {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$BasePath
|
||||
)
|
||||
|
||||
if ([System.IO.Path]::IsPathRooted($Path)) {
|
||||
return [System.IO.Path]::GetFullPath($Path)
|
||||
}
|
||||
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $Path))
|
||||
}
|
||||
|
||||
function Get-RelativePath {
|
||||
param(
|
||||
[string]$Root,
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$rootPrefix = [System.IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
|
||||
$fullPath = [System.IO.Path]::GetFullPath($Path)
|
||||
if (-not $fullPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Path is outside repository root: $fullPath"
|
||||
}
|
||||
return $fullPath.Substring($rootPrefix.Length).Replace('\', '/')
|
||||
}
|
||||
|
||||
function Get-TextFiles {
|
||||
param(
|
||||
[string]$Root,
|
||||
[object[]]$Paths
|
||||
)
|
||||
|
||||
$allowedExtensions = @(".h", ".hpp", ".cpp", ".cxx", ".md", ".txt", ".json", ".cmake", ".ps1")
|
||||
$files = New-Object System.Collections.Generic.List[System.IO.FileInfo]
|
||||
foreach ($relativePath in $Paths) {
|
||||
$absolutePath = Resolve-AbsolutePath -Path ([string]$relativePath) -BasePath $Root
|
||||
if (-not (Test-Path -LiteralPath $absolutePath)) {
|
||||
continue
|
||||
}
|
||||
$item = Get-Item -LiteralPath $absolutePath
|
||||
if (-not $item.PSIsContainer) {
|
||||
$files.Add($item)
|
||||
continue
|
||||
}
|
||||
foreach ($file in Get-ChildItem -LiteralPath $absolutePath -Recurse -File) {
|
||||
if ($allowedExtensions -contains $file.Extension.ToLowerInvariant()) {
|
||||
$files.Add($file)
|
||||
}
|
||||
}
|
||||
}
|
||||
return @($files | Sort-Object FullName -Unique)
|
||||
}
|
||||
|
||||
function Invoke-GitDiffCheck {
|
||||
param([string]$Root)
|
||||
|
||||
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$startInfo.FileName = "git"
|
||||
$escapedRoot = $Root.Replace('"', '\"')
|
||||
$startInfo.Arguments = "-C `"$escapedRoot`" diff --check"
|
||||
$startInfo.UseShellExecute = $false
|
||||
$startInfo.CreateNoWindow = $true
|
||||
$startInfo.RedirectStandardOutput = $true
|
||||
$startInfo.RedirectStandardError = $true
|
||||
|
||||
$process = New-Object System.Diagnostics.Process
|
||||
$process.StartInfo = $startInfo
|
||||
if (-not $process.Start()) {
|
||||
throw "Unable to start git for diff validation."
|
||||
}
|
||||
$standardOutput = $process.StandardOutput.ReadToEnd()
|
||||
$standardError = $process.StandardError.ReadToEnd()
|
||||
$process.WaitForExit()
|
||||
|
||||
return [pscustomobject]@{
|
||||
ExitCode = $process.ExitCode
|
||||
Output = ($standardOutput + $standardError).Trim()
|
||||
}
|
||||
}
|
||||
|
||||
$defaultRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..\.."))
|
||||
$resolvedRoot = if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
|
||||
$defaultRoot
|
||||
} else {
|
||||
Resolve-AbsolutePath -Path $RepoRoot -BasePath (Get-Location)
|
||||
}
|
||||
$resolvedConfig = if ([string]::IsNullOrWhiteSpace($ConfigPath)) {
|
||||
Join-Path $resolvedRoot "internal/qa/config/static-checks.json"
|
||||
} else {
|
||||
Resolve-AbsolutePath -Path $ConfigPath -BasePath $resolvedRoot
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $resolvedConfig -PathType Leaf)) {
|
||||
throw "Static-check configuration was not found: $resolvedConfig"
|
||||
}
|
||||
|
||||
$config = Get-Content -LiteralPath $resolvedConfig -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($config.schemaVersion -ne 1) {
|
||||
throw "Unsupported static-check configuration version: $($config.schemaVersion)"
|
||||
}
|
||||
|
||||
$results = New-Object System.Collections.Generic.List[object]
|
||||
function Add-Result {
|
||||
param(
|
||||
[string]$Name,
|
||||
[bool]$Passed,
|
||||
[string]$Details
|
||||
)
|
||||
$results.Add([pscustomobject]@{
|
||||
Name = $Name
|
||||
Passed = $Passed
|
||||
Details = $Details
|
||||
})
|
||||
}
|
||||
|
||||
$cmakePath = Resolve-AbsolutePath -Path ([string]$config.productionCmakeFile) -BasePath $resolvedRoot
|
||||
$cmakeText = Get-Content -LiteralPath $cmakePath -Raw -Encoding UTF8
|
||||
$productionFiles = New-Object System.Collections.Generic.List[System.IO.FileInfo]
|
||||
foreach ($sourceRoot in $config.productionSourceRoots) {
|
||||
$sourceRootPath = Resolve-AbsolutePath -Path ([string]$sourceRoot) -BasePath $resolvedRoot
|
||||
foreach ($file in Get-ChildItem -LiteralPath $sourceRootPath -Recurse -File) {
|
||||
if ($config.productionExtensions -contains $file.Extension.ToLowerInvariant()) {
|
||||
$productionFiles.Add($file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$missingFromCmake = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($file in $productionFiles) {
|
||||
$relativePath = Get-RelativePath -Root $resolvedRoot -Path $file.FullName
|
||||
if (-not $cmakeText.Contains($relativePath)) {
|
||||
$missingFromCmake.Add($relativePath)
|
||||
}
|
||||
}
|
||||
Add-Result -Name "Production sources are registered in CMake" `
|
||||
-Passed ($missingFromCmake.Count -eq 0) `
|
||||
-Details ($(if ($missingFromCmake.Count -eq 0) { "$($productionFiles.Count) source/header files registered" } else { "Missing: " + ($missingFromCmake -join ", ") }))
|
||||
|
||||
$missingCmakeReferences = New-Object System.Collections.Generic.List[string]
|
||||
$cmakeReferencePattern = 'src[\\/][A-Za-z0-9_.\\/-]+\.(?:h|cpp)'
|
||||
foreach ($match in [regex]::Matches($cmakeText, $cmakeReferencePattern)) {
|
||||
$relativePath = $match.Value.Replace('\', '/')
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $resolvedRoot $relativePath) -PathType Leaf)) {
|
||||
$missingCmakeReferences.Add($relativePath)
|
||||
}
|
||||
}
|
||||
Add-Result -Name "CMake does not reference missing production sources" `
|
||||
-Passed ($missingCmakeReferences.Count -eq 0) `
|
||||
-Details ($(if ($missingCmakeReferences.Count -eq 0) { "No missing source references" } else { "Missing: " + (($missingCmakeReferences | Sort-Object -Unique) -join ", ") }))
|
||||
|
||||
$qaCmakePath = Resolve-AbsolutePath -Path ([string]$config.qaCmakeFile) -BasePath $resolvedRoot
|
||||
$qaRoot = Split-Path $qaCmakePath -Parent
|
||||
$qaCmakeText = Get-Content -LiteralPath $qaCmakePath -Raw -Encoding UTF8
|
||||
$missingQaTests = New-Object System.Collections.Generic.List[string]
|
||||
$qaTestCount = 0
|
||||
foreach ($testRoot in $config.qaTestRoots) {
|
||||
$testRootPath = Resolve-AbsolutePath -Path ([string]$testRoot) -BasePath $resolvedRoot
|
||||
foreach ($testFile in Get-ChildItem -LiteralPath $testRootPath -Recurse -Filter "*.cpp" -File) {
|
||||
++$qaTestCount
|
||||
$relativeTestPath = Get-RelativePath -Root $qaRoot -Path $testFile.FullName
|
||||
if (-not $qaCmakeText.Contains($relativeTestPath)) {
|
||||
$missingQaTests.Add($relativeTestPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
Add-Result -Name "Internal QA test sources are registered in the QA CMake project" `
|
||||
-Passed ($missingQaTests.Count -eq 0) `
|
||||
-Details ($(if ($missingQaTests.Count -eq 0) { "$qaTestCount test translation units registered" } else { "Missing: " + ($missingQaTests -join ", ") }))
|
||||
|
||||
$activeFiles = Get-TextFiles -Root $resolvedRoot -Paths $config.activeScanPaths
|
||||
foreach ($rule in $config.forbiddenReferences) {
|
||||
$matches = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($file in $activeFiles) {
|
||||
$content = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8
|
||||
if ($content -match $rule.pattern) {
|
||||
$matches.Add((Get-RelativePath -Root $resolvedRoot -Path $file.FullName))
|
||||
}
|
||||
}
|
||||
Add-Result -Name "No $($rule.name)" `
|
||||
-Passed ($matches.Count -eq 0) `
|
||||
-Details ($(if ($matches.Count -eq 0) { "No active references" } else { "Found in: " + (($matches | Sort-Object -Unique) -join ", ") }))
|
||||
}
|
||||
|
||||
if ($config.forbidQObjectMacro) {
|
||||
$qObjectFiles = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($file in $productionFiles) {
|
||||
$content = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8
|
||||
if ($content -match '\bQ_OBJECT\b') {
|
||||
$qObjectFiles.Add((Get-RelativePath -Root $resolvedRoot -Path $file.FullName))
|
||||
}
|
||||
}
|
||||
Add-Result -Name "Production code remains compatible with CMAKE_AUTOMOC OFF" `
|
||||
-Passed ($qObjectFiles.Count -eq 0) `
|
||||
-Details ($(if ($qObjectFiles.Count -eq 0) { "No Q_OBJECT macro found" } else { "Found in: " + ($qObjectFiles -join ", ") }))
|
||||
}
|
||||
|
||||
$missingRequiredPaths = @($config.requiredPaths | Where-Object {
|
||||
-not (Test-Path -LiteralPath (Resolve-AbsolutePath -Path ([string]$_) -BasePath $resolvedRoot))
|
||||
})
|
||||
Add-Result -Name "Required release inputs exist" `
|
||||
-Passed ($missingRequiredPaths.Count -eq 0) `
|
||||
-Details ($(if ($missingRequiredPaths.Count -eq 0) { "$($config.requiredPaths.Count) required paths found" } else { "Missing: " + ($missingRequiredPaths -join ", ") }))
|
||||
|
||||
$characterManifestPath = Join-Path $resolvedRoot "resources/characters/shiroko/character.json"
|
||||
$characterProblems = New-Object System.Collections.Generic.List[string]
|
||||
try {
|
||||
$character = Get-Content -LiteralPath $characterManifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ([string]::IsNullOrWhiteSpace([string]$character.defaultState)) {
|
||||
$characterProblems.Add("defaultState is empty")
|
||||
}
|
||||
foreach ($stateProperty in $character.states.PSObject.Properties) {
|
||||
$statePath = Join-Path (Split-Path $characterManifestPath -Parent) ([string]$stateProperty.Value.path)
|
||||
if (-not (Test-Path -LiteralPath $statePath -PathType Container)) {
|
||||
$characterProblems.Add("state directory missing: $($stateProperty.Name)")
|
||||
continue
|
||||
}
|
||||
if (@(Get-ChildItem -LiteralPath $statePath -Filter "*.png" -File).Count -eq 0) {
|
||||
$characterProblems.Add("state has no PNG frames: $($stateProperty.Name)")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$characterProblems.Add("manifest parse failed: $($_.Exception.Message)")
|
||||
}
|
||||
Add-Result -Name "Default character manifest and frames are complete" `
|
||||
-Passed ($characterProblems.Count -eq 0) `
|
||||
-Details ($(if ($characterProblems.Count -eq 0) { "All declared states contain PNG frames" } else { $characterProblems -join "; " }))
|
||||
|
||||
$exportScriptPath = Join-Path $resolvedRoot "tools/prepare_github_export.ps1"
|
||||
$exportScriptText = Get-Content -LiteralPath $exportScriptPath -Raw -Encoding UTF8
|
||||
$missingExportExclusions = @($config.publicExportExcludedRoots | Where-Object {
|
||||
$quotedName = '"' + [regex]::Escape([string]$_) + '"'
|
||||
$exportScriptText -notmatch $quotedName
|
||||
})
|
||||
Add-Result -Name "Public GitHub export excludes internal development roots" `
|
||||
-Passed ($missingExportExclusions.Count -eq 0) `
|
||||
-Details ($(if ($missingExportExclusions.Count -eq 0) { "Excluded: " + ($config.publicExportExcludedRoots -join ", ") } else { "Not excluded: " + ($missingExportExclusions -join ", ") }))
|
||||
|
||||
$releaseIsolationMatches = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($relativeFile in $config.releaseIsolationFiles) {
|
||||
$absoluteFile = Resolve-AbsolutePath -Path ([string]$relativeFile) -BasePath $resolvedRoot
|
||||
$content = Get-Content -LiteralPath $absoluteFile -Raw -Encoding UTF8
|
||||
foreach ($pattern in $config.releaseForbiddenPatterns) {
|
||||
if ($content -match $pattern) {
|
||||
$releaseIsolationMatches.Add("$relativeFile -> $pattern")
|
||||
}
|
||||
}
|
||||
}
|
||||
Add-Result -Name "Production build and packaging do not depend on internal QA" `
|
||||
-Passed ($releaseIsolationMatches.Count -eq 0) `
|
||||
-Details ($(if ($releaseIsolationMatches.Count -eq 0) { "No internal QA dependency" } else { $releaseIsolationMatches -join "; " }))
|
||||
|
||||
$infrastructureProblems = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($scriptFile in Get-ChildItem -LiteralPath (Join-Path $resolvedRoot "internal/qa/scripts") -Filter "*.ps1" -File) {
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||
$scriptFile.FullName,
|
||||
[ref]$tokens,
|
||||
[ref]$parseErrors) | Out-Null
|
||||
foreach ($parseError in $parseErrors) {
|
||||
$infrastructureProblems.Add("$($scriptFile.Name):$($parseError.Extent.StartLineNumber): $($parseError.Message)")
|
||||
}
|
||||
}
|
||||
foreach ($jsonFile in Get-ChildItem -LiteralPath (Join-Path $resolvedRoot "internal/qa/config") -Filter "*.json" -File) {
|
||||
try {
|
||||
Get-Content -LiteralPath $jsonFile.FullName -Raw -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
} catch {
|
||||
$infrastructureProblems.Add("$($jsonFile.Name): $($_.Exception.Message)")
|
||||
}
|
||||
}
|
||||
Add-Result -Name "Internal QA scripts and configuration parse successfully" `
|
||||
-Passed ($infrastructureProblems.Count -eq 0) `
|
||||
-Details ($(if ($infrastructureProblems.Count -eq 0) { "All PowerShell and JSON files parsed" } else { $infrastructureProblems -join "; " }))
|
||||
|
||||
$gitResult = Invoke-GitDiffCheck -Root $resolvedRoot
|
||||
Add-Result -Name "Git diff whitespace check" `
|
||||
-Passed ($gitResult.ExitCode -eq 0) `
|
||||
-Details ($(if ($gitResult.ExitCode -eq 0) { "git diff --check passed" } else { $gitResult.Output }))
|
||||
|
||||
$passedCount = @($results | Where-Object Passed).Count
|
||||
$failedResults = @($results | Where-Object { -not $_.Passed })
|
||||
foreach ($result in $results) {
|
||||
$prefix = if ($result.Passed) { "PASS" } else { "FAIL" }
|
||||
Write-Host "[$prefix] $($result.Name) - $($result.Details)"
|
||||
}
|
||||
Write-Host "Static checks: $passedCount passed, $($failedResults.Count) failed."
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($ReportPath)) {
|
||||
$resolvedReportPath = Resolve-AbsolutePath -Path $ReportPath -BasePath $resolvedRoot
|
||||
$reportDirectory = Split-Path $resolvedReportPath -Parent
|
||||
if (-not (Test-Path -LiteralPath $reportDirectory)) {
|
||||
New-Item -ItemType Directory -Path $reportDirectory -Force | Out-Null
|
||||
}
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
$lines.Add("# Static Check Report")
|
||||
$lines.Add("")
|
||||
$lines.Add("Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')")
|
||||
$lines.Add("")
|
||||
$lines.Add("| Check | Result | Details |")
|
||||
$lines.Add("| --- | --- | --- |")
|
||||
foreach ($result in $results) {
|
||||
$status = if ($result.Passed) { "PASS" } else { "FAIL" }
|
||||
$details = ([string]$result.Details).Replace("|", "\|").Replace("`r", " ").Replace("`n", " ")
|
||||
$lines.Add("| $($result.Name) | $status | $details |")
|
||||
}
|
||||
Set-Content -LiteralPath $resolvedReportPath -Value $lines -Encoding UTF8
|
||||
Write-Host "Report: $resolvedReportPath"
|
||||
}
|
||||
|
||||
if ($failedResults.Count -gt 0) {
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$InputPath,
|
||||
[string]$Scenario = "",
|
||||
[string]$ThresholdConfigPath = "",
|
||||
[string]$OutputPath = ""
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Resolve-AbsolutePath {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$BasePath
|
||||
)
|
||||
if ([System.IO.Path]::IsPathRooted($Path)) {
|
||||
return [System.IO.Path]::GetFullPath($Path)
|
||||
}
|
||||
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $Path))
|
||||
}
|
||||
|
||||
function Convert-ToDouble {
|
||||
param([object]$Value)
|
||||
return [double]::Parse(
|
||||
[string]$Value,
|
||||
[System.Globalization.NumberStyles]::Float,
|
||||
[System.Globalization.CultureInfo]::InvariantCulture)
|
||||
}
|
||||
|
||||
function Get-Percentile {
|
||||
param(
|
||||
[double[]]$Values,
|
||||
[double]$Percentile
|
||||
)
|
||||
if ($Values.Count -eq 0) { return 0.0 }
|
||||
$sorted = @($Values | Sort-Object)
|
||||
$index = [math]::Max(0, [math]::Ceiling($Percentile * $sorted.Count) - 1)
|
||||
return [double]$sorted[$index]
|
||||
}
|
||||
|
||||
function Get-PropertyValue {
|
||||
param(
|
||||
[object]$Object,
|
||||
[string]$Name
|
||||
)
|
||||
if ($null -eq $Object) { return $null }
|
||||
$property = $Object.PSObject.Properties[$Name]
|
||||
if ($null -eq $property) { return $null }
|
||||
return $property.Value
|
||||
}
|
||||
|
||||
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..\.."))
|
||||
$resolvedInput = Resolve-AbsolutePath -Path $InputPath -BasePath $repoRoot
|
||||
if (-not (Test-Path -LiteralPath $resolvedInput -PathType Leaf)) {
|
||||
throw "Performance CSV was not found: $resolvedInput"
|
||||
}
|
||||
|
||||
$resolvedThresholds = if ([string]::IsNullOrWhiteSpace($ThresholdConfigPath)) {
|
||||
Join-Path $repoRoot "internal/qa/config/performance-thresholds.json"
|
||||
} else {
|
||||
Resolve-AbsolutePath -Path $ThresholdConfigPath -BasePath $repoRoot
|
||||
}
|
||||
$thresholdConfig = Get-Content -LiteralPath $resolvedThresholds -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($thresholdConfig.schemaVersion -ne 1) {
|
||||
throw "Unsupported performance threshold configuration version: $($thresholdConfig.schemaVersion)"
|
||||
}
|
||||
|
||||
$allRows = @(Import-Csv -LiteralPath $resolvedInput)
|
||||
$runningRows = @($allRows | Where-Object { $_.Status -eq "running" })
|
||||
if ($runningRows.Count -eq 0) {
|
||||
throw "Performance CSV contains no running samples: $resolvedInput"
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Scenario)) {
|
||||
$scenarioProperty = $runningRows[0].PSObject.Properties["Scenario"]
|
||||
$Scenario = if ($null -ne $scenarioProperty -and -not [string]::IsNullOrWhiteSpace([string]$scenarioProperty.Value)) {
|
||||
[string]$scenarioProperty.Value
|
||||
} else {
|
||||
[System.IO.Path]::GetFileNameWithoutExtension($resolvedInput)
|
||||
}
|
||||
}
|
||||
|
||||
$cpuValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.CpuPercent })
|
||||
$workingSetValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.WorkingSetMB })
|
||||
$privateMemoryValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.PrivateMemoryMB })
|
||||
$handleValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.HandleCount })
|
||||
$threadValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.ThreadCount })
|
||||
|
||||
$metrics = [ordered]@{
|
||||
SampleCount = $runningRows.Count
|
||||
AverageCpuPercent = [math]::Round(($cpuValues | Measure-Object -Average).Average, 2)
|
||||
P95CpuPercent = [math]::Round((Get-Percentile -Values $cpuValues -Percentile 0.95), 2)
|
||||
PeakCpuPercent = [math]::Round(($cpuValues | Measure-Object -Maximum).Maximum, 2)
|
||||
StartWorkingSetMB = $workingSetValues[0]
|
||||
EndWorkingSetMB = $workingSetValues[-1]
|
||||
PeakWorkingSetMB = [math]::Round(($workingSetValues | Measure-Object -Maximum).Maximum, 2)
|
||||
WorkingSetGrowthMB = [math]::Round($workingSetValues[-1] - $workingSetValues[0], 2)
|
||||
StartPrivateMemoryMB = $privateMemoryValues[0]
|
||||
EndPrivateMemoryMB = $privateMemoryValues[-1]
|
||||
PeakPrivateMemoryMB = [math]::Round(($privateMemoryValues | Measure-Object -Maximum).Maximum, 2)
|
||||
PrivateMemoryGrowthMB = [math]::Round($privateMemoryValues[-1] - $privateMemoryValues[0], 2)
|
||||
HandleGrowth = [int]($handleValues[-1] - $handleValues[0])
|
||||
PeakHandleCount = [int](($handleValues | Measure-Object -Maximum).Maximum)
|
||||
ThreadGrowth = [int]($threadValues[-1] - $threadValues[0])
|
||||
PeakThreadCount = [int](($threadValues | Measure-Object -Maximum).Maximum)
|
||||
UnresponsiveSamples = @($runningRows | Where-Object { ([string]$_.Responding).ToLowerInvariant() -eq "false" }).Count
|
||||
UnexpectedExit = @($allRows | Where-Object { $_.Status -eq "exited" }).Count -gt 0
|
||||
}
|
||||
|
||||
$scenarioThresholds = Get-PropertyValue -Object $thresholdConfig.scenarios -Name $Scenario
|
||||
$effectiveThresholds = @{}
|
||||
foreach ($property in $thresholdConfig.defaults.PSObject.Properties) {
|
||||
$effectiveThresholds[$property.Name] = $property.Value
|
||||
}
|
||||
if ($null -ne $scenarioThresholds) {
|
||||
foreach ($property in $scenarioThresholds.PSObject.Properties) {
|
||||
$effectiveThresholds[$property.Name] = $property.Value
|
||||
}
|
||||
}
|
||||
|
||||
$checks = New-Object System.Collections.Generic.List[object]
|
||||
function Add-ThresholdCheck {
|
||||
param(
|
||||
[string]$Name,
|
||||
[double]$Actual,
|
||||
[string]$ThresholdName,
|
||||
[string]$Unit
|
||||
)
|
||||
$threshold = $effectiveThresholds[$ThresholdName]
|
||||
if ($null -eq $threshold) { return }
|
||||
$limit = Convert-ToDouble $threshold
|
||||
$checks.Add([pscustomobject]@{
|
||||
Name = $Name
|
||||
Passed = $Actual -le $limit
|
||||
Actual = "$Actual$Unit"
|
||||
Limit = "<= $limit$Unit"
|
||||
})
|
||||
}
|
||||
|
||||
$allowUnexpectedExit = [bool]$effectiveThresholds["allowUnexpectedExit"]
|
||||
$checks.Add([pscustomobject]@{
|
||||
Name = "Unexpected process exit"
|
||||
Passed = $allowUnexpectedExit -or -not $metrics.UnexpectedExit
|
||||
Actual = [string]$metrics.UnexpectedExit
|
||||
Limit = if ($allowUnexpectedExit) { "allowed" } else { "False" }
|
||||
})
|
||||
Add-ThresholdCheck -Name "Unresponsive samples" -Actual $metrics.UnresponsiveSamples -ThresholdName "maxUnresponsiveSamples" -Unit ""
|
||||
Add-ThresholdCheck -Name "Average CPU" -Actual $metrics.AverageCpuPercent -ThresholdName "maxAverageCpuPercent" -Unit "%"
|
||||
Add-ThresholdCheck -Name "P95 CPU" -Actual $metrics.P95CpuPercent -ThresholdName "maxP95CpuPercent" -Unit "%"
|
||||
Add-ThresholdCheck -Name "Private memory growth" -Actual $metrics.PrivateMemoryGrowthMB -ThresholdName "maxPrivateMemoryGrowthMB" -Unit " MB"
|
||||
Add-ThresholdCheck -Name "Working set growth" -Actual $metrics.WorkingSetGrowthMB -ThresholdName "maxWorkingSetGrowthMB" -Unit " MB"
|
||||
Add-ThresholdCheck -Name "Handle growth" -Actual $metrics.HandleGrowth -ThresholdName "maxHandleGrowth" -Unit ""
|
||||
Add-ThresholdCheck -Name "Thread growth" -Actual $metrics.ThreadGrowth -ThresholdName "maxThreadGrowth" -Unit ""
|
||||
|
||||
$failedChecks = @($checks | Where-Object { -not $_.Passed })
|
||||
$result = if ($failedChecks.Count -eq 0) { "PASS" } else { "FAIL" }
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||
$safeScenario = $Scenario -replace '[^a-zA-Z0-9._-]', '_'
|
||||
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$OutputPath = "internal/qa/out/performance/$timestamp-$safeScenario.md"
|
||||
}
|
||||
$resolvedOutput = Resolve-AbsolutePath -Path $OutputPath -BasePath $repoRoot
|
||||
$outputDirectory = Split-Path $resolvedOutput -Parent
|
||||
if (-not (Test-Path -LiteralPath $outputDirectory)) {
|
||||
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
|
||||
}
|
||||
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
$lines.Add("# Performance Summary")
|
||||
$lines.Add("")
|
||||
$lines.Add("- Scenario: ``$Scenario``")
|
||||
$lines.Add("- Result: **$result**")
|
||||
$lines.Add("- Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')")
|
||||
$lines.Add("- Source CSV: ``$resolvedInput``")
|
||||
$lines.Add("")
|
||||
$lines.Add("## Metrics")
|
||||
$lines.Add("")
|
||||
$lines.Add("| Metric | Value |")
|
||||
$lines.Add("| --- | ---: |")
|
||||
foreach ($entry in $metrics.GetEnumerator()) {
|
||||
$lines.Add("| $($entry.Key) | $($entry.Value) |")
|
||||
}
|
||||
$lines.Add("")
|
||||
$lines.Add("## Threshold Checks")
|
||||
$lines.Add("")
|
||||
$lines.Add("| Check | Result | Actual | Limit |")
|
||||
$lines.Add("| --- | --- | ---: | ---: |")
|
||||
foreach ($check in $checks) {
|
||||
$status = if ($check.Passed) { "PASS" } else { "FAIL" }
|
||||
$lines.Add("| $($check.Name) | $status | $($check.Actual) | $($check.Limit) |")
|
||||
}
|
||||
$lines.Add("")
|
||||
$lines.Add("Resource thresholds left as ``null`` are reported but not used as release gates. Calibrate them after a reviewed Release baseline is available.")
|
||||
|
||||
Set-Content -LiteralPath $resolvedOutput -Value $lines -Encoding UTF8
|
||||
Write-Host "Performance report: $resolvedOutput"
|
||||
Write-Host "Result: $result"
|
||||
|
||||
if ($failedChecks.Count -gt 0) {
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
param(
|
||||
[string]$ProcessName = "QtDesktopPet",
|
||||
[Alias("Pid")]
|
||||
[int]$ProcessId = 0,
|
||||
[ValidateRange(1, 86400)]
|
||||
[int]$IntervalSeconds = 5,
|
||||
[ValidateRange(1, 604800)]
|
||||
[int]$DurationSeconds = 300,
|
||||
[string]$Scenario = "unspecified",
|
||||
[string]$OutputPath = "reports/perf"
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Resolve-TargetProcess {
|
||||
param(
|
||||
[string]$Name,
|
||||
[int]$Id
|
||||
)
|
||||
|
||||
if ($Id -gt 0) {
|
||||
return Get-Process -Id $Id -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$processes = @(Get-Process -Name $Name -ErrorAction SilentlyContinue)
|
||||
if ($processes.Count -eq 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($processes.Count -gt 1) {
|
||||
Write-Warning "Multiple processes named '$Name' were found. Sampling PID $($processes[0].Id). Use -Pid to target a specific process."
|
||||
}
|
||||
|
||||
return $processes[0]
|
||||
}
|
||||
|
||||
function Read-ProcessSnapshot {
|
||||
param([System.Diagnostics.Process]$Process)
|
||||
|
||||
$path = ""
|
||||
try { $path = $Process.Path } catch { $path = "" }
|
||||
|
||||
$responding = ""
|
||||
try { $responding = $Process.Responding } catch { $responding = "" }
|
||||
|
||||
return [pscustomobject]@{
|
||||
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
|
||||
Pid = $Process.Id
|
||||
CpuSeconds = [double]($Process.CPU)
|
||||
WorkingSetMB = [math]::Round($Process.WorkingSet64 / 1MB, 2)
|
||||
PrivateMemoryMB = [math]::Round($Process.PrivateMemorySize64 / 1MB, 2)
|
||||
HandleCount = $Process.HandleCount
|
||||
ThreadCount = $Process.Threads.Count
|
||||
Responding = $responding
|
||||
Path = $path
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-OutputDirectory {
|
||||
param([string]$Path)
|
||||
|
||||
if ([System.IO.Path]::IsPathRooted($Path)) {
|
||||
return [System.IO.Path]::GetFullPath($Path)
|
||||
}
|
||||
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..\.."))
|
||||
return [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path))
|
||||
}
|
||||
|
||||
function New-OutputFilePath {
|
||||
param(
|
||||
[string]$Directory,
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Directory)) {
|
||||
New-Item -ItemType Directory -Force -Path $Directory | Out-Null
|
||||
}
|
||||
|
||||
$safeName = $Name -replace '[^a-zA-Z0-9._-]', '_'
|
||||
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
return Join-Path $Directory "$timestamp-$safeName.csv"
|
||||
}
|
||||
|
||||
$target = Resolve-TargetProcess -Name $ProcessName -Id $ProcessId
|
||||
if ($null -eq $target) {
|
||||
if ($ProcessId -gt 0) {
|
||||
Write-Error "Process with PID $ProcessId was not found."
|
||||
} else {
|
||||
Write-Error "Process '$ProcessName' was not found. Start the app first or pass -Pid."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
$logicalProcessorCount = [math]::Max(1, [Environment]::ProcessorCount)
|
||||
$outputDirectory = Resolve-OutputDirectory -Path $OutputPath
|
||||
$outputFile = New-OutputFilePath -Directory $outputDirectory -Name "$Scenario-$($target.ProcessName)"
|
||||
$sampleCount = [math]::Max(1, [math]::Ceiling($DurationSeconds / $IntervalSeconds))
|
||||
|
||||
Write-Host "Sampling scenario '$Scenario' for PID $($target.Id) ($($target.ProcessName)) every $IntervalSeconds seconds for up to $DurationSeconds seconds."
|
||||
Write-Host "Output: $outputFile"
|
||||
|
||||
$previousSnapshot = Read-ProcessSnapshot -Process $target
|
||||
$previousTime = Get-Date
|
||||
$firstRow = [pscustomobject]@{
|
||||
Scenario = $Scenario
|
||||
SampleIndex = 0
|
||||
TimestampUtc = $previousSnapshot.TimestampUtc
|
||||
Pid = $previousSnapshot.Pid
|
||||
CpuPercent = 0
|
||||
WorkingSetMB = $previousSnapshot.WorkingSetMB
|
||||
PrivateMemoryMB = $previousSnapshot.PrivateMemoryMB
|
||||
HandleCount = $previousSnapshot.HandleCount
|
||||
ThreadCount = $previousSnapshot.ThreadCount
|
||||
Responding = $previousSnapshot.Responding
|
||||
Path = $previousSnapshot.Path
|
||||
Status = "running"
|
||||
}
|
||||
$firstRow | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8
|
||||
|
||||
for ($index = 1; $index -le $sampleCount; $index++) {
|
||||
Start-Sleep -Seconds $IntervalSeconds
|
||||
|
||||
$currentProcess = Get-Process -Id $target.Id -ErrorAction SilentlyContinue
|
||||
if ($null -eq $currentProcess) {
|
||||
[pscustomobject]@{
|
||||
Scenario = $Scenario
|
||||
SampleIndex = $index
|
||||
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
|
||||
Pid = $target.Id
|
||||
CpuPercent = ""
|
||||
WorkingSetMB = ""
|
||||
PrivateMemoryMB = ""
|
||||
HandleCount = ""
|
||||
ThreadCount = ""
|
||||
Responding = ""
|
||||
Path = $previousSnapshot.Path
|
||||
Status = "exited"
|
||||
} | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append
|
||||
|
||||
Write-Warning "Process $($target.Id) exited. Sampling stopped."
|
||||
break
|
||||
}
|
||||
|
||||
$currentSnapshot = Read-ProcessSnapshot -Process $currentProcess
|
||||
$currentTime = Get-Date
|
||||
$elapsedSeconds = [math]::Max(0.001, ($currentTime - $previousTime).TotalSeconds)
|
||||
$cpuPercent = (($currentSnapshot.CpuSeconds - $previousSnapshot.CpuSeconds) / $elapsedSeconds) * 100 / $logicalProcessorCount
|
||||
if ($cpuPercent -lt 0) { $cpuPercent = 0 }
|
||||
|
||||
[pscustomobject]@{
|
||||
Scenario = $Scenario
|
||||
SampleIndex = $index
|
||||
TimestampUtc = $currentSnapshot.TimestampUtc
|
||||
Pid = $currentSnapshot.Pid
|
||||
CpuPercent = [math]::Round($cpuPercent, 2)
|
||||
WorkingSetMB = $currentSnapshot.WorkingSetMB
|
||||
PrivateMemoryMB = $currentSnapshot.PrivateMemoryMB
|
||||
HandleCount = $currentSnapshot.HandleCount
|
||||
ThreadCount = $currentSnapshot.ThreadCount
|
||||
Responding = $currentSnapshot.Responding
|
||||
Path = $currentSnapshot.Path
|
||||
Status = "running"
|
||||
} | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append
|
||||
|
||||
$previousSnapshot = $currentSnapshot
|
||||
$previousTime = $currentTime
|
||||
}
|
||||
|
||||
Write-Host "Sampling finished: $outputFile"
|
||||
@@ -0,0 +1,44 @@
|
||||
#include "TestHarness.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QStandardPaths>
|
||||
#include <QTextStream>
|
||||
|
||||
#include <exception>
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QStandardPaths::setTestModeEnabled(true);
|
||||
QCoreApplication application(argc, argv);
|
||||
QCoreApplication::setOrganizationName(QStringLiteral("QtDesktopPetInternalQA"));
|
||||
QCoreApplication::setApplicationName(QStringLiteral("CoreLogicTests"));
|
||||
|
||||
QTextStream output(stdout);
|
||||
QTextStream errors(stderr);
|
||||
int failedCount = 0;
|
||||
|
||||
for (const Qa::TestCase &test : Qa::registry())
|
||||
{
|
||||
try
|
||||
{
|
||||
test.function();
|
||||
output << "[PASS] " << QString::fromStdString(test.name) << Qt::endl;
|
||||
}
|
||||
catch (const std::exception &exception)
|
||||
{
|
||||
++failedCount;
|
||||
errors << "[FAIL] " << QString::fromStdString(test.name)
|
||||
<< ": " << exception.what() << Qt::endl;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
++failedCount;
|
||||
errors << "[FAIL] " << QString::fromStdString(test.name)
|
||||
<< ": unknown exception" << Qt::endl;
|
||||
}
|
||||
}
|
||||
|
||||
output << "Executed " << Qa::registry().size() << " tests; "
|
||||
<< failedCount << " failed." << Qt::endl;
|
||||
return failedCount == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
#include "TestHarness.h"
|
||||
|
||||
#include "src/ai/ConversationStore.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDate>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTime>
|
||||
|
||||
namespace
|
||||
{
|
||||
ChatMessage message(const QString &role, const QString &content, int minute)
|
||||
{
|
||||
return {
|
||||
role,
|
||||
content,
|
||||
QDateTime(QDate(2026, 7, 17), QTime(10, minute)),
|
||||
QStringLiteral("openai"),
|
||||
QStringLiteral("test-model"),
|
||||
};
|
||||
}
|
||||
|
||||
bool writeBytes(const QString &filePath, const QByteArray &bytes)
|
||||
{
|
||||
QFile file(filePath);
|
||||
return file.open(QIODevice::WriteOnly | QIODevice::Truncate)
|
||||
&& file.write(bytes) == bytes.size();
|
||||
}
|
||||
}
|
||||
|
||||
QA_TEST(ConversationStore, SavesLoadsAndLimitsEvenHistory)
|
||||
{
|
||||
QTemporaryDir directory;
|
||||
QA_EXPECT(directory.isValid());
|
||||
const QString filePath = directory.filePath(QStringLiteral("conversation_history.json"));
|
||||
const ConversationStore store(filePath);
|
||||
const QVector<ChatMessage> messages = {
|
||||
message(QStringLiteral("user"), QStringLiteral("one"), 0),
|
||||
message(QStringLiteral("assistant"), QStringLiteral("two"), 1),
|
||||
message(QStringLiteral("user"), QStringLiteral("three"), 2),
|
||||
message(QStringLiteral("assistant"), QStringLiteral("four"), 3),
|
||||
};
|
||||
|
||||
QA_EXPECT(store.save(messages, 4));
|
||||
const QVector<ChatMessage> loaded = store.load(3);
|
||||
QA_EXPECT_EQ(loaded.size(), 2);
|
||||
QA_EXPECT_EQ(loaded.first().content, QStringLiteral("three"));
|
||||
QA_EXPECT_EQ(loaded.last().model, QStringLiteral("test-model"));
|
||||
}
|
||||
|
||||
QA_TEST(ConversationStore, AvoidsDuplicatingOverlappingMessages)
|
||||
{
|
||||
QTemporaryDir directory;
|
||||
QA_EXPECT(directory.isValid());
|
||||
const ConversationStore store(directory.filePath(QStringLiteral("history.json")));
|
||||
const QVector<ChatMessage> firstPair = {
|
||||
message(QStringLiteral("user"), QStringLiteral("hello"), 0),
|
||||
message(QStringLiteral("assistant"), QStringLiteral("world"), 1),
|
||||
};
|
||||
|
||||
QA_EXPECT(store.save(firstPair, 10));
|
||||
QA_EXPECT(store.save(firstPair, 10));
|
||||
QA_EXPECT_EQ(store.load(10).size(), 2);
|
||||
}
|
||||
|
||||
QA_TEST(ConversationStore, LoadsLegacyCreatedAtField)
|
||||
{
|
||||
QTemporaryDir directory;
|
||||
QA_EXPECT(directory.isValid());
|
||||
const QString filePath = directory.filePath(QStringLiteral("legacy.json"));
|
||||
QJsonArray messages;
|
||||
messages.append(QJsonObject{
|
||||
{QStringLiteral("role"), QStringLiteral("user")},
|
||||
{QStringLiteral("content"), QStringLiteral("legacy-user")},
|
||||
{QStringLiteral("createdAt"), QStringLiteral("2026-07-17T10:00:00")},
|
||||
});
|
||||
messages.append(QJsonObject{
|
||||
{QStringLiteral("role"), QStringLiteral("assistant")},
|
||||
{QStringLiteral("content"), QStringLiteral("legacy-assistant")},
|
||||
{QStringLiteral("createdAt"), QStringLiteral("2026-07-17T10:00:01")},
|
||||
});
|
||||
QA_EXPECT(writeBytes(filePath, QJsonDocument(QJsonObject{{QStringLiteral("messages"), messages}}).toJson()));
|
||||
|
||||
const QVector<ChatMessage> loaded = ConversationStore(filePath).load(10);
|
||||
QA_EXPECT_EQ(loaded.size(), 2);
|
||||
QA_EXPECT(loaded.first().timestamp.isValid());
|
||||
QA_EXPECT(loaded.first().provider.isEmpty());
|
||||
}
|
||||
|
||||
QA_TEST(ConversationStore, BacksUpBrokenJsonAndClearsHistory)
|
||||
{
|
||||
QTemporaryDir directory;
|
||||
QA_EXPECT(directory.isValid());
|
||||
const QString filePath = directory.filePath(QStringLiteral("conversation_history.json"));
|
||||
QA_EXPECT(writeBytes(filePath, QByteArrayLiteral("{broken")));
|
||||
|
||||
QString errorMessage;
|
||||
const ConversationStore store(filePath);
|
||||
QA_EXPECT(store.load(10, &errorMessage).isEmpty());
|
||||
QA_EXPECT(!errorMessage.isEmpty());
|
||||
QA_EXPECT(!QFile::exists(filePath));
|
||||
QA_EXPECT_EQ(
|
||||
QDir(directory.path()).entryList({QStringLiteral("conversation_history.broken.*.json")}, QDir::Files).size(),
|
||||
1);
|
||||
|
||||
QA_EXPECT(store.save({
|
||||
message(QStringLiteral("user"), QStringLiteral("hello"), 0),
|
||||
message(QStringLiteral("assistant"), QStringLiteral("world"), 1),
|
||||
}, 10));
|
||||
QA_EXPECT(store.clear());
|
||||
QA_EXPECT(!QFile::exists(filePath));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#include "TestHarness.h"
|
||||
|
||||
#include "src/assistant/CommandDispatcher.h"
|
||||
#include "src/assistant/IntentRouter.h"
|
||||
|
||||
QA_TEST(IntentRouting, TrimsChatText)
|
||||
{
|
||||
const UserIntent intent = IntentRouter().route(QStringLiteral(" 你好 "));
|
||||
QA_EXPECT_EQ(intent.type, UserIntentType::Chat);
|
||||
QA_EXPECT_EQ(intent.text, QStringLiteral("你好"));
|
||||
}
|
||||
|
||||
QA_TEST(IntentRouting, ReminderHasPriorityOverWeather)
|
||||
{
|
||||
const UserIntent intent = IntentRouter().route(QStringLiteral("明天提醒我看天气"));
|
||||
QA_EXPECT_EQ(intent.type, UserIntentType::Reminder);
|
||||
}
|
||||
|
||||
QA_TEST(IntentRouting, RecognizesWeatherExpressions)
|
||||
{
|
||||
const IntentRouter router;
|
||||
QA_EXPECT_EQ(router.route(QStringLiteral("西安天气怎么样")).type, UserIntentType::Weather);
|
||||
QA_EXPECT_EQ(router.route(QStringLiteral("明天会不会下雨")).type, UserIntentType::Weather);
|
||||
QA_EXPECT_EQ(router.route(QStringLiteral("外面冷不冷")).type, UserIntentType::Weather);
|
||||
}
|
||||
|
||||
QA_TEST(IntentRouting, RemovedLocalCapabilitiesRemainChat)
|
||||
{
|
||||
const IntentRouter router;
|
||||
QA_EXPECT_EQ(router.route(QStringLiteral("读取文件")).type, UserIntentType::Chat);
|
||||
QA_EXPECT_EQ(router.route(QStringLiteral("打开 Codex")).type, UserIntentType::Chat);
|
||||
QA_EXPECT_EQ(router.route(QStringLiteral("分析这个项目")).type, UserIntentType::Chat);
|
||||
QA_EXPECT_EQ(router.route(QStringLiteral("创建 test.txt")).type, UserIntentType::Chat);
|
||||
}
|
||||
|
||||
QA_TEST(IntentRouting, DispatcherMapsSupportedActions)
|
||||
{
|
||||
const CommandDispatcher dispatcher;
|
||||
QA_EXPECT_EQ(dispatcher.dispatch(QStringLiteral("提醒我休息")).action, CommandDispatchAction::Reminder);
|
||||
QA_EXPECT_EQ(dispatcher.dispatch(QStringLiteral("北京天气")).action, CommandDispatchAction::Weather);
|
||||
QA_EXPECT_EQ(dispatcher.dispatch(QStringLiteral("讲个笑话")).action, CommandDispatchAction::Chat);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
#include "TestHarness.h"
|
||||
|
||||
#include "src/reminder/ReminderParser.h"
|
||||
#include "src/reminder/ReminderTypes.h"
|
||||
|
||||
#include <QDate>
|
||||
#include <QDateTime>
|
||||
#include <QStringList>
|
||||
#include <QTime>
|
||||
|
||||
namespace
|
||||
{
|
||||
QDateTime fixedNow()
|
||||
{
|
||||
return QDateTime(QDate(2026, 7, 17), QTime(14, 0));
|
||||
}
|
||||
}
|
||||
|
||||
QA_TEST(ReminderParser, ParsesRelativeMinutes)
|
||||
{
|
||||
const ReminderCommand command = ReminderParser().parse(QStringLiteral("10分钟后提醒我喝水"), fixedNow());
|
||||
QA_EXPECT_EQ(command.type, ReminderCommandType::Create);
|
||||
QA_EXPECT_EQ(command.remindAt, fixedNow().addSecs(10 * 60));
|
||||
QA_EXPECT_EQ(command.title, QStringLiteral("喝水"));
|
||||
}
|
||||
|
||||
QA_TEST(ReminderParser, ParsesHalfHours)
|
||||
{
|
||||
const ReminderParser parser;
|
||||
QA_EXPECT_EQ(
|
||||
parser.parse(QStringLiteral("半小时后提醒我休息"), fixedNow()).remindAt,
|
||||
fixedNow().addSecs(30 * 60));
|
||||
QA_EXPECT_EQ(
|
||||
parser.parse(QStringLiteral("一个半小时后提醒我喝水"), fixedNow()).remindAt,
|
||||
fixedNow().addSecs(90 * 60));
|
||||
}
|
||||
|
||||
QA_TEST(ReminderParser, ParsesTomorrowClockTime)
|
||||
{
|
||||
const ReminderCommand command = ReminderParser().parse(QStringLiteral("明天9点提醒我开会"), fixedNow());
|
||||
QA_EXPECT_EQ(command.type, ReminderCommandType::Create);
|
||||
QA_EXPECT_EQ(command.remindAt, QDateTime(QDate(2026, 7, 18), QTime(9, 0)));
|
||||
}
|
||||
|
||||
QA_TEST(ReminderParser, ParsesSupportedRecurrence)
|
||||
{
|
||||
const ReminderParser parser;
|
||||
const ReminderCommand daily = parser.parse(QStringLiteral("每天9点提醒我打卡"), fixedNow());
|
||||
const ReminderCommand weekly = parser.parse(QStringLiteral("每周一上午10点提醒我周会"), fixedNow());
|
||||
const ReminderCommand monthly = parser.parse(QStringLiteral("每月31号9点提醒我交报告"), fixedNow());
|
||||
|
||||
QA_EXPECT_EQ(daily.recurrence.type, ReminderRecurrenceType::Daily);
|
||||
QA_EXPECT_EQ(daily.remindAt, QDateTime(QDate(2026, 7, 18), QTime(9, 0)));
|
||||
QA_EXPECT_EQ(weekly.recurrence.type, ReminderRecurrenceType::Weekly);
|
||||
QA_EXPECT_EQ(weekly.recurrence.weekday, 1);
|
||||
QA_EXPECT_EQ(monthly.recurrence.type, ReminderRecurrenceType::Monthly);
|
||||
QA_EXPECT_EQ(monthly.recurrence.monthDay, 31);
|
||||
QA_EXPECT_EQ(monthly.remindAt, QDateTime(QDate(2026, 7, 31), QTime(9, 0)));
|
||||
}
|
||||
|
||||
QA_TEST(ReminderParser, RejectsUnsupportedRecurrence)
|
||||
{
|
||||
const ReminderParser parser;
|
||||
const QStringList requests = {
|
||||
QStringLiteral("工作日9点提醒我打卡"),
|
||||
QStringLiteral("每两天提醒我喝水"),
|
||||
QStringLiteral("农历初一提醒我"),
|
||||
QStringLiteral("每月最后一天9点提醒我结算"),
|
||||
};
|
||||
|
||||
for (const QString &request : requests)
|
||||
{
|
||||
const ReminderCommand command = parser.parse(request, fixedNow());
|
||||
QA_EXPECT_EQ(command.type, ReminderCommandType::Invalid);
|
||||
QA_EXPECT(!command.errorMessage.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
QA_TEST(ReminderParser, ParsesListAndCancelCommands)
|
||||
{
|
||||
const ReminderParser parser;
|
||||
QA_EXPECT_EQ(parser.parse(QStringLiteral("提醒列表"), fixedNow()).type, ReminderCommandType::List);
|
||||
|
||||
const ReminderCommand cancel = parser.parse(QStringLiteral("取消喝水提醒"), fixedNow());
|
||||
QA_EXPECT_EQ(cancel.type, ReminderCommandType::Cancel);
|
||||
QA_EXPECT_EQ(cancel.cancelQuery, QStringLiteral("喝水"));
|
||||
}
|
||||
|
||||
QA_TEST(ReminderTypes, ConvertsAndSortsValues)
|
||||
{
|
||||
QA_EXPECT_EQ(reminderStatusFromString(QStringLiteral("TRIGGERED")), ReminderStatus::Triggered);
|
||||
QA_EXPECT_EQ(reminderRecurrenceTypeFromString(QStringLiteral("weekly")), ReminderRecurrenceType::Weekly);
|
||||
QA_EXPECT_EQ(reminderRecurrenceTypeToString(ReminderRecurrenceType::Monthly), QStringLiteral("monthly"));
|
||||
|
||||
ReminderItem later;
|
||||
later.id = QStringLiteral("later");
|
||||
later.remindAt = fixedNow().addSecs(60);
|
||||
ReminderItem earlier;
|
||||
earlier.id = QStringLiteral("earlier");
|
||||
earlier.remindAt = fixedNow();
|
||||
|
||||
const QVector<ReminderItem> sorted = sortedReminders({later, earlier});
|
||||
QA_EXPECT_EQ(sorted.first().id, QStringLiteral("earlier"));
|
||||
QA_EXPECT_EQ(sorted.last().id, QStringLiteral("later"));
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#include "TestHarness.h"
|
||||
|
||||
#include "src/weather/WeatherParser.h"
|
||||
#include "src/weather/WeatherSummaryFormatter.h"
|
||||
|
||||
QA_TEST(WeatherParser, ExtractsExplicitCitiesAndDates)
|
||||
{
|
||||
const WeatherParser parser;
|
||||
const WeatherQuery current = parser.parse(QStringLiteral("西安天气怎么样"));
|
||||
const WeatherQuery tomorrow = parser.parse(QStringLiteral("明天西安天气怎么样"));
|
||||
const WeatherQuery afterTomorrow = parser.parse(QStringLiteral("后天纽约天气"));
|
||||
|
||||
QA_EXPECT_EQ(current.cityName, QStringLiteral("西安"));
|
||||
QA_EXPECT_EQ(current.kind, WeatherQueryKind::Current);
|
||||
QA_EXPECT_EQ(tomorrow.cityName, QStringLiteral("西安"));
|
||||
QA_EXPECT_EQ(tomorrow.dateOffset, 1);
|
||||
QA_EXPECT_EQ(afterTomorrow.cityName, QStringLiteral("纽约"));
|
||||
QA_EXPECT_EQ(afterTomorrow.dateOffset, 2);
|
||||
}
|
||||
|
||||
QA_TEST(WeatherParser, ParsesRangeAndDefaultCityRequest)
|
||||
{
|
||||
const WeatherParser parser;
|
||||
const WeatherQuery range = parser.parse(QStringLiteral("未来三天北京天气"));
|
||||
const WeatherQuery defaultCity = parser.parse(QStringLiteral("今天天气怎么样"));
|
||||
|
||||
QA_EXPECT_EQ(range.kind, WeatherQueryKind::Range);
|
||||
QA_EXPECT_EQ(range.forecastDays, 3);
|
||||
QA_EXPECT_EQ(range.cityName, QStringLiteral("北京"));
|
||||
QA_EXPECT(defaultCity.cityName.isEmpty());
|
||||
}
|
||||
|
||||
QA_TEST(WeatherParser, RejectsUnsupportedQueries)
|
||||
{
|
||||
const WeatherParser parser;
|
||||
QA_EXPECT(!parser.parse(QStringLiteral("西安空气质量")).unsupportedReason.isEmpty());
|
||||
QA_EXPECT(!parser.parse(QStringLiteral("北京天气预警")).unsupportedReason.isEmpty());
|
||||
QA_EXPECT(!parser.parse(QStringLiteral("明天穿什么")).unsupportedReason.isEmpty());
|
||||
}
|
||||
|
||||
QA_TEST(WeatherFormatter, MapsWeatherCodesAndWindDirections)
|
||||
{
|
||||
QA_EXPECT_EQ(WeatherSummaryFormatter::weatherCodeText(0), QStringLiteral("晴"));
|
||||
QA_EXPECT_EQ(WeatherSummaryFormatter::weatherCodeText(95), QStringLiteral("雷暴"));
|
||||
QA_EXPECT_EQ(WeatherSummaryFormatter::weatherCodeText(-1), QStringLiteral("未知天气"));
|
||||
QA_EXPECT_EQ(WeatherSummaryFormatter::windDirectionText(0), QStringLiteral("北风"));
|
||||
QA_EXPECT_EQ(WeatherSummaryFormatter::windDirectionText(90), QStringLiteral("东风"));
|
||||
QA_EXPECT_EQ(WeatherSummaryFormatter::windDirectionText(225), QStringLiteral("西南风"));
|
||||
}
|
||||
|
||||
QA_TEST(WeatherFormatter, FormatsCurrentWeatherWithSource)
|
||||
{
|
||||
WeatherReport report;
|
||||
report.query.kind = WeatherQueryKind::Current;
|
||||
report.location.cityName = QStringLiteral("西安");
|
||||
report.location.countryName = QStringLiteral("中国");
|
||||
report.location.source = WeatherLocationSource::SettingsDefault;
|
||||
report.current.valid = true;
|
||||
report.current.weatherCode = 0;
|
||||
report.current.hasTemperature = true;
|
||||
report.current.temperatureC = 28.5;
|
||||
|
||||
const QString message = WeatherSummaryFormatter().format(report);
|
||||
QA_EXPECT(message.contains(QStringLiteral("使用设置页默认城市")));
|
||||
QA_EXPECT(message.contains(QStringLiteral("西安,中国当前天气:晴")));
|
||||
QA_EXPECT(message.contains(QStringLiteral("28.5℃")));
|
||||
}
|
||||
|
||||
QA_TEST(WeatherFormatter, FormatsAmbiguousLocationAndMissingData)
|
||||
{
|
||||
WeatherReport report;
|
||||
report.query.kind = WeatherQueryKind::Current;
|
||||
report.location.cityName = QStringLiteral("Springfield");
|
||||
report.location.countryName = QStringLiteral("United States");
|
||||
report.hasLocationAmbiguity = true;
|
||||
report.locationCandidates = {
|
||||
{QStringLiteral("Springfield"), QStringLiteral("Illinois"), QStringLiteral("United States"), {}, 0.0, 0.0},
|
||||
{QStringLiteral("Springfield"), QStringLiteral("Missouri"), QStringLiteral("United States"), {}, 0.0, 0.0},
|
||||
};
|
||||
|
||||
const QString message = WeatherSummaryFormatter().format(report);
|
||||
QA_EXPECT(message.contains(QStringLiteral("可能存在同名城市")));
|
||||
QA_EXPECT(message.contains(QStringLiteral("Missouri")));
|
||||
QA_EXPECT(message.contains(QStringLiteral("缺少当前天气")));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#include "TestHarness.h"
|
||||
|
||||
#include "src/config/AIConfig.h"
|
||||
#include "src/web/WebCapabilityDetector.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
AIConfig completeConfig(const QString &provider)
|
||||
{
|
||||
AIConfig config = defaultAIConfigForProvider(provider);
|
||||
config.model = QStringLiteral("test-model");
|
||||
config.apiKey = QStringLiteral("test-key-not-a-real-secret");
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
QA_TEST(WebCapability, RespectsGlobalDisable)
|
||||
{
|
||||
WebConfig webConfig;
|
||||
webConfig.enabled = false;
|
||||
const WebCapability capability = WebCapabilityDetector::detect(completeConfig(QStringLiteral("openai")), webConfig);
|
||||
QA_EXPECT(!capability.supported);
|
||||
QA_EXPECT(capability.statusText.contains(QStringLiteral("已关闭")));
|
||||
}
|
||||
|
||||
QA_TEST(WebCapability, AcceptsOfficialOpenAI)
|
||||
{
|
||||
const WebCapability capability = WebCapabilityDetector::detect(completeConfig(QStringLiteral("openai")));
|
||||
QA_EXPECT(capability.supported);
|
||||
QA_EXPECT_EQ(capability.kind, WebProviderKind::OpenAIResponses);
|
||||
}
|
||||
|
||||
QA_TEST(WebCapability, RejectsThirdPartyOpenAIBaseUrl)
|
||||
{
|
||||
AIConfig config = completeConfig(QStringLiteral("openai"));
|
||||
config.baseUrl = QStringLiteral("https://relay.example.com/v1");
|
||||
const WebCapability capability = WebCapabilityDetector::detect(config);
|
||||
QA_EXPECT(!capability.supported);
|
||||
QA_EXPECT(capability.userMessage.contains(QStringLiteral("api.openai.com")));
|
||||
}
|
||||
|
||||
QA_TEST(WebCapability, AcceptsGeminiProtocol)
|
||||
{
|
||||
const WebCapability capability = WebCapabilityDetector::detect(completeConfig(QStringLiteral("google")));
|
||||
QA_EXPECT(capability.supported);
|
||||
QA_EXPECT_EQ(capability.kind, WebProviderKind::GeminiGrounding);
|
||||
}
|
||||
|
||||
QA_TEST(WebCapability, RejectsDeepSeekAndCustomProviders)
|
||||
{
|
||||
const WebCapability deepSeek = WebCapabilityDetector::detect(completeConfig(QStringLiteral("deepseek")));
|
||||
const WebCapability custom = WebCapabilityDetector::detect(completeConfig(QStringLiteral("custom")));
|
||||
QA_EXPECT(!deepSeek.supported);
|
||||
QA_EXPECT(deepSeek.statusText.contains(QStringLiteral("DeepSeek")));
|
||||
QA_EXPECT(!custom.supported);
|
||||
QA_EXPECT(custom.statusText.contains(QStringLiteral("无法确认")));
|
||||
}
|
||||
|
||||
QA_TEST(WebCapability, RequiresCompleteConfiguration)
|
||||
{
|
||||
AIConfig config = defaultAIConfigForProvider(QStringLiteral("openai"));
|
||||
const WebCapability capability = WebCapabilityDetector::detect(config);
|
||||
QA_EXPECT(!capability.supported);
|
||||
QA_EXPECT(capability.statusText.contains(QStringLiteral("未配置完整")));
|
||||
}
|
||||
Reference in New Issue
Block a user