完成 0.2.0 预发布收口与中文安装器
This commit is contained in:
@@ -14,7 +14,7 @@ if (NOT EXISTS "${PET_SOURCE_ROOT}/src" OR NOT EXISTS "${PET_SOURCE_ROOT}/CMakeL
|
||||
message(FATAL_ERROR "PET_SOURCE_ROOT does not point to a QtDesktopPet source tree: ${PET_SOURCE_ROOT}")
|
||||
endif()
|
||||
|
||||
find_package(Qt6 REQUIRED COMPONENTS Core)
|
||||
find_package(Qt6 REQUIRED COMPONENTS Core Network)
|
||||
|
||||
add_executable(QtDesktopPetCoreTests
|
||||
include/TestHarness.h
|
||||
@@ -24,6 +24,8 @@ add_executable(QtDesktopPetCoreTests
|
||||
tests/weather/WeatherTests.cpp
|
||||
tests/web/WebCapabilityTests.cpp
|
||||
tests/ai/ConversationStoreTests.cpp
|
||||
tests/ai/AIDiagnosticsTests.cpp
|
||||
tests/config/ConfigRecoveryTests.cpp
|
||||
${PET_SOURCE_ROOT}/src/assistant/IntentRouter.h
|
||||
${PET_SOURCE_ROOT}/src/assistant/IntentRouter.cpp
|
||||
${PET_SOURCE_ROOT}/src/assistant/CommandDispatcher.h
|
||||
@@ -35,6 +37,10 @@ add_executable(QtDesktopPetCoreTests
|
||||
${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/WeatherLocationResolver.h
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherLocationResolver.cpp
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherManager.h
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherManager.cpp
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherSummaryFormatter.h
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherSummaryFormatter.cpp
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherTypes.h
|
||||
@@ -45,9 +51,20 @@ add_executable(QtDesktopPetCoreTests
|
||||
${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/config/AppConfig.h
|
||||
${PET_SOURCE_ROOT}/src/config/ConfigManager.h
|
||||
${PET_SOURCE_ROOT}/src/config/ConfigManager.cpp
|
||||
${PET_SOURCE_ROOT}/src/ai/AIDiagnostics.h
|
||||
${PET_SOURCE_ROOT}/src/ai/AIDiagnostics.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/weather/WeatherStore.h
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherStore.cpp
|
||||
${PET_SOURCE_ROOT}/src/web/WebStore.h
|
||||
${PET_SOURCE_ROOT}/src/web/WebStore.cpp
|
||||
${PET_SOURCE_ROOT}/src/reminder/ReminderStore.h
|
||||
${PET_SOURCE_ROOT}/src/reminder/ReminderStore.cpp
|
||||
${PET_SOURCE_ROOT}/src/util/Logger.h
|
||||
${PET_SOURCE_ROOT}/src/util/Logger.cpp
|
||||
)
|
||||
@@ -58,7 +75,7 @@ target_include_directories(QtDesktopPetCoreTests
|
||||
"${PET_SOURCE_ROOT}"
|
||||
)
|
||||
|
||||
target_link_libraries(QtDesktopPetCoreTests PRIVATE Qt6::Core)
|
||||
target_link_libraries(QtDesktopPetCoreTests PRIVATE Qt6::Core Qt6::Network)
|
||||
|
||||
include(CTest)
|
||||
add_test(NAME QtDesktopPetCoreTests COMMAND QtDesktopPetCoreTests)
|
||||
|
||||
@@ -27,6 +27,8 @@ 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.
|
||||
To isolate a suite while diagnosing failures, run the test executable with a
|
||||
case-insensitive filter, for example `QtDesktopPetCoreTests.exe --filter=Weather`.
|
||||
|
||||
## Static Checks
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
"resources/characters/shiroko/preview.png",
|
||||
"resources/icons/app_icon.ico",
|
||||
"resources/sounds/reminders/reminder_default.wav",
|
||||
"installer/languages/ChineseSimplified.isl",
|
||||
"installer/languages/LICENSE.txt",
|
||||
"tools/package_release.ps1",
|
||||
"tools/prepare_github_export.ps1"
|
||||
],
|
||||
@@ -55,5 +57,52 @@
|
||||
"releaseForbiddenPatterns": [
|
||||
"internal/qa",
|
||||
"internal\\\\qa"
|
||||
]
|
||||
],
|
||||
"releaseValidation": {
|
||||
"powershellParseFiles": [
|
||||
"internal/qa/scripts/Invoke-StaticChecks.ps1",
|
||||
"internal/qa/scripts/New-PerformanceReport.ps1",
|
||||
"internal/qa/scripts/perf_sample.ps1",
|
||||
"tools/package_release.ps1",
|
||||
"tools/prepare_github_export.ps1"
|
||||
],
|
||||
"packageScript": {
|
||||
"path": "tools/package_release.ps1",
|
||||
"requiredPatterns": [
|
||||
{ "name": "Qt deployment tool", "pattern": "windeployqt" },
|
||||
{ "name": "release runtime deployment", "pattern": "--release" },
|
||||
{ "name": "compiler runtime deployment", "pattern": "--compiler-runtime" },
|
||||
{ "name": "MSVC app-local deployment", "pattern": "Copy-MsvcRuntime" },
|
||||
{ "name": "MSVC runtime input", "pattern": "MsvcRuntimeDir" },
|
||||
{ "name": "MSVC runtime validation", "pattern": "vcruntime140_1\\.dll" },
|
||||
{ "name": "character resources", "pattern": "resources\\\\characters" },
|
||||
{ "name": "icon resources", "pattern": "resources\\\\icons" },
|
||||
{ "name": "sound resources", "pattern": "resources\\\\sounds" },
|
||||
{ "name": "README", "pattern": "README\\.md" },
|
||||
{ "name": "LICENSE", "pattern": "LICENSE" },
|
||||
{ "name": "installer build switch", "pattern": "BuildInstaller" },
|
||||
{ "name": "Inno Setup compiler", "pattern": "ISCC\\.exe" }
|
||||
],
|
||||
"forbiddenPatterns": [
|
||||
{ "name": "internal QA copy", "pattern": "Copy-(?:Item|DirectoryFresh)[^\\r\\n]*internal[\\\\/]qa" },
|
||||
{ "name": "development docs copy", "pattern": "Copy-(?:Item|DirectoryFresh)[^\\r\\n]*docs" },
|
||||
{ "name": "test reports copy", "pattern": "Copy-(?:Item|DirectoryFresh)[^\\r\\n]*reports" }
|
||||
]
|
||||
},
|
||||
"installerScript": {
|
||||
"path": "installer/QtDesktopPet.iss",
|
||||
"requiredPatterns": [
|
||||
{ "name": "per-user install", "pattern": "PrivilegesRequired=lowest" },
|
||||
{ "name": "x64 compatibility", "pattern": "ArchitecturesAllowed=x64compatible" },
|
||||
{ "name": "Simplified Chinese installer", "pattern": "ChineseSimplified\\.isl" },
|
||||
{ "name": "language dialog disabled", "pattern": "ShowLanguageDialog=no" },
|
||||
{ "name": "unchecked startup option", "pattern": "Name:\\s*\"startup\"[^\\r\\n]*Flags:\\s*unchecked" },
|
||||
{ "name": "startup registry entry", "pattern": "Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run" },
|
||||
{ "name": "recursive package files", "pattern": "recursesubdirs\\s+createallsubdirs" },
|
||||
{ "name": "uninstall data prompt", "pattern": "InitializeUninstall" },
|
||||
{ "name": "optional user data cleanup", "pattern": "DeleteUserDataAfterUninstall" },
|
||||
{ "name": "startup registry cleanup", "pattern": "RegDeleteValue" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,8 +258,36 @@ 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 "; " }))
|
||||
|
||||
$releaseValidationProblems = New-Object System.Collections.Generic.List[string]
|
||||
$packageValidation = $config.releaseValidation.packageScript
|
||||
$packageScriptPath = Resolve-AbsolutePath -Path ([string]$packageValidation.path) -BasePath $resolvedRoot
|
||||
$packageScriptText = Get-Content -LiteralPath $packageScriptPath -Raw -Encoding UTF8
|
||||
foreach ($rule in $packageValidation.requiredPatterns) {
|
||||
if ($packageScriptText -notmatch ([string]$rule.pattern)) {
|
||||
$releaseValidationProblems.Add("package missing $($rule.name)")
|
||||
}
|
||||
}
|
||||
foreach ($rule in $packageValidation.forbiddenPatterns) {
|
||||
if ($packageScriptText -match ([string]$rule.pattern)) {
|
||||
$releaseValidationProblems.Add("package contains $($rule.name)")
|
||||
}
|
||||
}
|
||||
|
||||
$installerValidation = $config.releaseValidation.installerScript
|
||||
$installerScriptPath = Resolve-AbsolutePath -Path ([string]$installerValidation.path) -BasePath $resolvedRoot
|
||||
$installerScriptText = Get-Content -LiteralPath $installerScriptPath -Raw -Encoding UTF8
|
||||
foreach ($rule in $installerValidation.requiredPatterns) {
|
||||
if ($installerScriptText -notmatch ([string]$rule.pattern)) {
|
||||
$releaseValidationProblems.Add("installer missing $($rule.name)")
|
||||
}
|
||||
}
|
||||
Add-Result -Name "Release packaging and installer contain required safeguards" `
|
||||
-Passed ($releaseValidationProblems.Count -eq 0) `
|
||||
-Details ($(if ($releaseValidationProblems.Count -eq 0) { "Package inputs, QA isolation, startup option, and uninstall cleanup are declared" } else { $releaseValidationProblems -join "; " }))
|
||||
|
||||
$infrastructureProblems = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($scriptFile in Get-ChildItem -LiteralPath (Join-Path $resolvedRoot "internal/qa/scripts") -Filter "*.ps1" -File) {
|
||||
foreach ($relativeScriptPath in $config.releaseValidation.powershellParseFiles) {
|
||||
$scriptFile = Get-Item -LiteralPath (Resolve-AbsolutePath -Path ([string]$relativeScriptPath) -BasePath $resolvedRoot)
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||
|
||||
@@ -15,14 +15,32 @@ int main(int argc, char *argv[])
|
||||
|
||||
QTextStream output(stdout);
|
||||
QTextStream errors(stderr);
|
||||
QString filter;
|
||||
for (int index = 1; index < argc; ++index)
|
||||
{
|
||||
const QString argument = QString::fromLocal8Bit(argv[index]);
|
||||
if (argument.startsWith(QStringLiteral("--filter=")))
|
||||
{
|
||||
filter = argument.mid(QStringLiteral("--filter=").size()).trimmed();
|
||||
}
|
||||
}
|
||||
|
||||
int failedCount = 0;
|
||||
int executedCount = 0;
|
||||
|
||||
for (const Qa::TestCase &test : Qa::registry())
|
||||
{
|
||||
const QString testName = QString::fromStdString(test.name);
|
||||
if (!filter.isEmpty() && !testName.contains(filter, Qt::CaseInsensitive))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
++executedCount;
|
||||
try
|
||||
{
|
||||
test.function();
|
||||
output << "[PASS] " << QString::fromStdString(test.name) << Qt::endl;
|
||||
output << "[PASS] " << testName << Qt::endl;
|
||||
}
|
||||
catch (const std::exception &exception)
|
||||
{
|
||||
@@ -38,7 +56,7 @@ int main(int argc, char *argv[])
|
||||
}
|
||||
}
|
||||
|
||||
output << "Executed " << Qa::registry().size() << " tests; "
|
||||
output << "Executed " << executedCount << " tests; "
|
||||
<< failedCount << " failed." << Qt::endl;
|
||||
return failedCount == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#include "TestHarness.h"
|
||||
|
||||
#include "src/ai/AIDiagnostics.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QUrl>
|
||||
|
||||
QA_TEST(AIDiagnostics, RedactsCredentialsFromContextAndFreeText)
|
||||
{
|
||||
AIConfig config;
|
||||
config.provider = QStringLiteral("custom");
|
||||
config.protocol = QStringLiteral("openai-compatible");
|
||||
config.model = QStringLiteral("test-model");
|
||||
config.apiKey = QStringLiteral("sk-super-secret-value");
|
||||
|
||||
const QString context = AIDiagnostics::diagnosticContext(
|
||||
config,
|
||||
QUrl(QStringLiteral("https://example.test/v1/chat?api_key=query-secret&trace=visible")));
|
||||
const QString freeText = AIDiagnostics::safeTextSummary(
|
||||
QStringLiteral("Authorization: Bearer bearer-secret token=token-secret sk-another-secret-value"));
|
||||
|
||||
QA_EXPECT(!context.contains(QStringLiteral("sk-super-secret-value")));
|
||||
QA_EXPECT(!context.contains(QStringLiteral("query-secret")));
|
||||
QA_EXPECT(context.contains(QStringLiteral("trace=visible")));
|
||||
QA_EXPECT(!freeText.contains(QStringLiteral("bearer-secret")));
|
||||
QA_EXPECT(!freeText.contains(QStringLiteral("token-secret")));
|
||||
QA_EXPECT(!freeText.contains(QStringLiteral("sk-another-secret-value")));
|
||||
}
|
||||
|
||||
QA_TEST(AIDiagnostics, RedactsConversationAndCredentialFieldsFromJson)
|
||||
{
|
||||
const QByteArray body = QByteArrayLiteral(
|
||||
R"({"safe":"visible","apiKey":"api-secret","authorization":"Bearer header-secret","messages":[{"content":"private prompt"}],"nested":{"token":"token-secret"}})");
|
||||
|
||||
const QString summary = AIDiagnostics::responseBodySummary(body);
|
||||
QA_EXPECT(summary.contains(QStringLiteral("visible")));
|
||||
QA_EXPECT(!summary.contains(QStringLiteral("api-secret")));
|
||||
QA_EXPECT(!summary.contains(QStringLiteral("header-secret")));
|
||||
QA_EXPECT(!summary.contains(QStringLiteral("private prompt")));
|
||||
QA_EXPECT(!summary.contains(QStringLiteral("token-secret")));
|
||||
}
|
||||
|
||||
QA_TEST(AIDiagnostics, KeepsUsefulErrorMetadataWithoutCredentialLeakage)
|
||||
{
|
||||
const QByteArray body = QByteArrayLiteral(
|
||||
R"({"error":{"message":"API key sk-super-secret-value is invalid","code":"invalid_api_key","type":"authentication_error"},"content":"private response"})");
|
||||
|
||||
const QString error = AIDiagnostics::errorMessageFromBody(body);
|
||||
QA_EXPECT(error.contains(QStringLiteral("invalid")));
|
||||
QA_EXPECT(error.contains(QStringLiteral("invalid_api_key")));
|
||||
QA_EXPECT(!error.contains(QStringLiteral("sk-super-secret-value")));
|
||||
QA_EXPECT(!error.contains(QStringLiteral("private response")));
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
#include "TestHarness.h"
|
||||
|
||||
#include "src/config/ConfigManager.h"
|
||||
#include "src/reminder/ReminderStore.h"
|
||||
#include "src/weather/WeatherStore.h"
|
||||
#include "src/web/WebStore.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
|
||||
namespace
|
||||
{
|
||||
bool writeBrokenJson(const QString &filePath)
|
||||
{
|
||||
QDir().mkpath(QFileInfo(filePath).absolutePath());
|
||||
QFile file(filePath);
|
||||
return file.open(QIODevice::WriteOnly | QIODevice::Truncate)
|
||||
&& file.write(QByteArrayLiteral("{broken")) == 7;
|
||||
}
|
||||
|
||||
QString qaConfigDirectory()
|
||||
{
|
||||
return QFileInfo(ConfigManager().appConfigPath()).absolutePath();
|
||||
}
|
||||
|
||||
bool isQaConfigDirectory(const QString &path)
|
||||
{
|
||||
return path.contains(QStringLiteral("QtDesktopPetInternalQA"), Qt::CaseInsensitive)
|
||||
|| path.contains(QStringLiteral("qttest"), Qt::CaseInsensitive);
|
||||
}
|
||||
|
||||
void clearQaConfigFiles()
|
||||
{
|
||||
const QString path = qaConfigDirectory();
|
||||
if (!isQaConfigDirectory(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QDir directory(path);
|
||||
const QStringList filters = {
|
||||
QStringLiteral("app_config.json"),
|
||||
QStringLiteral("app_config.broken.*.json"),
|
||||
QStringLiteral("ai_config.json"),
|
||||
QStringLiteral("ai_config.broken.*.json"),
|
||||
QStringLiteral("weather_config.json"),
|
||||
QStringLiteral("weather_config.broken.*.json"),
|
||||
QStringLiteral("web_config.json"),
|
||||
QStringLiteral("web_config.broken.*.json"),
|
||||
QStringLiteral("reminders.json"),
|
||||
QStringLiteral("reminders.broken.*.json"),
|
||||
};
|
||||
for (const QString &fileName : directory.entryList(filters, QDir::Files))
|
||||
{
|
||||
directory.remove(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
int backupCount(const QString &pattern)
|
||||
{
|
||||
return QDir(qaConfigDirectory()).entryList({pattern}, QDir::Files).size();
|
||||
}
|
||||
|
||||
class QaConfigFiles
|
||||
{
|
||||
public:
|
||||
QaConfigFiles()
|
||||
{
|
||||
QA_EXPECT(isQaConfigDirectory(qaConfigDirectory()));
|
||||
clearQaConfigFiles();
|
||||
}
|
||||
|
||||
~QaConfigFiles()
|
||||
{
|
||||
clearQaConfigFiles();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
QA_TEST(ConfigRecovery, AppConfigFallsBackAndBacksUpBrokenJson)
|
||||
{
|
||||
QaConfigFiles files;
|
||||
ConfigManager manager;
|
||||
QA_EXPECT(writeBrokenJson(manager.appConfigPath()));
|
||||
|
||||
const AppConfig config = manager.loadAppConfig();
|
||||
QA_EXPECT_EQ(config.windowPosition, QPoint(100, 100));
|
||||
QA_EXPECT(config.alwaysOnTop);
|
||||
QA_EXPECT(!QFile::exists(manager.appConfigPath()));
|
||||
QA_EXPECT_EQ(backupCount(QStringLiteral("app_config.broken.*.json")), 1);
|
||||
}
|
||||
|
||||
QA_TEST(ConfigRecovery, AiConfigFallsBackAndBacksUpBrokenJson)
|
||||
{
|
||||
QaConfigFiles files;
|
||||
ConfigManager manager;
|
||||
QA_EXPECT(writeBrokenJson(manager.aiConfigPath()));
|
||||
|
||||
const AIConfigStore store = manager.loadAIConfigStore();
|
||||
QA_EXPECT_EQ(store.activeProvider, QStringLiteral("custom"));
|
||||
QA_EXPECT(store.providers.isEmpty());
|
||||
QA_EXPECT(!QFile::exists(manager.aiConfigPath()));
|
||||
QA_EXPECT_EQ(backupCount(QStringLiteral("ai_config.broken.*.json")), 1);
|
||||
}
|
||||
|
||||
QA_TEST(ConfigRecovery, WeatherAndWebConfigsFallBackAndBackUpBrokenJson)
|
||||
{
|
||||
QaConfigFiles files;
|
||||
WeatherStore weatherStore;
|
||||
WebStore webStore;
|
||||
QA_EXPECT(writeBrokenJson(weatherStore.storePath()));
|
||||
QA_EXPECT(writeBrokenJson(webStore.storePath()));
|
||||
|
||||
QString weatherError;
|
||||
QString webError;
|
||||
const WeatherConfig weather = weatherStore.load(&weatherError);
|
||||
const WebConfig web = webStore.load(&webError);
|
||||
QA_EXPECT_EQ(weather.provider, QStringLiteral("open-meteo"));
|
||||
QA_EXPECT(weather.autoLocateWhenNoDefault);
|
||||
QA_EXPECT(web.enabled);
|
||||
QA_EXPECT(!web.defaultToggleOn);
|
||||
QA_EXPECT(!weatherError.isEmpty());
|
||||
QA_EXPECT(!webError.isEmpty());
|
||||
QA_EXPECT_EQ(backupCount(QStringLiteral("weather_config.broken.*.json")), 1);
|
||||
QA_EXPECT_EQ(backupCount(QStringLiteral("web_config.broken.*.json")), 1);
|
||||
}
|
||||
|
||||
QA_TEST(ConfigRecovery, ReminderStoreFallsBackToEmptyAndBacksUpBrokenJson)
|
||||
{
|
||||
QaConfigFiles files;
|
||||
ReminderStore store;
|
||||
QA_EXPECT(writeBrokenJson(store.storePath()));
|
||||
|
||||
QString error;
|
||||
QA_EXPECT(store.load(&error).isEmpty());
|
||||
QA_EXPECT(!error.isEmpty());
|
||||
QA_EXPECT(!QFile::exists(store.storePath()));
|
||||
QA_EXPECT_EQ(backupCount(QStringLiteral("reminders.broken.*.json")), 1);
|
||||
}
|
||||
|
||||
QA_TEST(ConfigRecovery, SameSecondBackupsNeverOverwriteEarlierFiles)
|
||||
{
|
||||
QaConfigFiles files;
|
||||
WeatherStore store;
|
||||
QA_EXPECT(writeBrokenJson(store.storePath()));
|
||||
store.load();
|
||||
QA_EXPECT(writeBrokenJson(store.storePath()));
|
||||
store.load();
|
||||
|
||||
QA_EXPECT_EQ(backupCount(QStringLiteral("weather_config.broken.*.json")), 2);
|
||||
}
|
||||
@@ -1,8 +1,34 @@
|
||||
#include "TestHarness.h"
|
||||
|
||||
#include "src/weather/WeatherParser.h"
|
||||
#include "src/weather/WeatherLocationResolver.h"
|
||||
#include "src/weather/WeatherSummaryFormatter.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
WeatherLocationCandidate candidate(
|
||||
const QString &city,
|
||||
const QString &admin,
|
||||
const QString &country,
|
||||
const QString &countryCode,
|
||||
const QString &featureCode,
|
||||
qint64 population,
|
||||
double latitude,
|
||||
double longitude)
|
||||
{
|
||||
WeatherLocationCandidate value;
|
||||
value.cityName = city;
|
||||
value.adminName = admin;
|
||||
value.countryName = country;
|
||||
value.countryCode = countryCode;
|
||||
value.featureCode = featureCode;
|
||||
value.population = population;
|
||||
value.latitude = latitude;
|
||||
value.longitude = longitude;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
QA_TEST(WeatherParser, ExtractsExplicitCitiesAndDates)
|
||||
{
|
||||
const WeatherParser parser;
|
||||
@@ -38,6 +64,120 @@ QA_TEST(WeatherParser, RejectsUnsupportedQueries)
|
||||
QA_EXPECT(!parser.parse(QStringLiteral("明天穿什么")).unsupportedReason.isEmpty());
|
||||
}
|
||||
|
||||
QA_TEST(WeatherLocationResolver, ResolvesChineseInternationalCities)
|
||||
{
|
||||
const WeatherLocationSearch newYork = WeatherLocationResolver::resolveSearch(QStringLiteral("纽约"));
|
||||
const WeatherLocationSearch tokyo = WeatherLocationResolver::resolveSearch(QStringLiteral("东京"));
|
||||
const WeatherLocationSearch japanTokyo = WeatherLocationResolver::resolveSearch(QStringLiteral("日本东京"));
|
||||
const WeatherLocationSearch qualifiedTokyo = WeatherLocationResolver::resolveSearch(QStringLiteral("东京,日本"));
|
||||
|
||||
QA_EXPECT_EQ(newYork.queryName, QStringLiteral("New York"));
|
||||
QA_EXPECT_EQ(newYork.countryCode, QStringLiteral("US"));
|
||||
QA_EXPECT_EQ(tokyo.queryName, QStringLiteral("Tokyo"));
|
||||
QA_EXPECT_EQ(tokyo.countryCode, QStringLiteral("JP"));
|
||||
QA_EXPECT_EQ(japanTokyo.queryName, QStringLiteral("Tokyo"));
|
||||
QA_EXPECT_EQ(japanTokyo.countryCode, QStringLiteral("JP"));
|
||||
QA_EXPECT_EQ(qualifiedTokyo.queryName, QStringLiteral("Tokyo"));
|
||||
QA_EXPECT_EQ(qualifiedTokyo.countryCode, QStringLiteral("JP"));
|
||||
}
|
||||
|
||||
QA_TEST(WeatherLocationResolver, PreservesDomesticCityAndCountryQualifier)
|
||||
{
|
||||
const WeatherLocationSearch xian = WeatherLocationResolver::resolveSearch(QStringLiteral("西安市"));
|
||||
const WeatherLocationSearch chinaXian = WeatherLocationResolver::resolveSearch(QStringLiteral("中国西安"));
|
||||
|
||||
QA_EXPECT_EQ(xian.queryName, QStringLiteral("西安"));
|
||||
QA_EXPECT(xian.countryCode.isEmpty());
|
||||
QA_EXPECT_EQ(chinaXian.queryName, QStringLiteral("西安"));
|
||||
QA_EXPECT_EQ(chinaXian.countryCode, QStringLiteral("CN"));
|
||||
}
|
||||
|
||||
QA_TEST(WeatherLocationResolver, SuppressesLowValueAlternatives)
|
||||
{
|
||||
const QVector<WeatherLocationCandidate> candidates = {
|
||||
candidate(QStringLiteral("西安"), QStringLiteral("陕西"), QStringLiteral("中国"), QStringLiteral("CN"), QStringLiteral("PPLA"), 6500000, 34.26, 108.93),
|
||||
candidate(QStringLiteral("西安"), QStringLiteral("湖南"), QStringLiteral("中国"), QStringLiteral("CN"), QStringLiteral("PPL"), 1200, 27.10, 111.20),
|
||||
candidate(QStringLiteral("西安"), QStringLiteral("广东"), QStringLiteral("中国"), QStringLiteral("CN"), QStringLiteral("PPL"), -1, 23.10, 113.20),
|
||||
};
|
||||
|
||||
const QVector<WeatherLocationCandidate> selected = WeatherLocationResolver::selectRelevantCandidates(
|
||||
candidates,
|
||||
WeatherLocationResolver::resolveSearch(QStringLiteral("西安")));
|
||||
QA_EXPECT_EQ(selected.size(), 1);
|
||||
QA_EXPECT(!WeatherLocationResolver::hasAmbiguity(selected));
|
||||
}
|
||||
|
||||
QA_TEST(WeatherLocationResolver, KeepsPlausibleAmbiguity)
|
||||
{
|
||||
const QVector<WeatherLocationCandidate> candidates = {
|
||||
candidate(QStringLiteral("Springfield"), QStringLiteral("Missouri"), QStringLiteral("United States"), QStringLiteral("US"), QStringLiteral("PPLA2"), 170000, 37.21, -93.29),
|
||||
candidate(QStringLiteral("Springfield"), QStringLiteral("Illinois"), QStringLiteral("United States"), QStringLiteral("US"), QStringLiteral("PPLA"), 114000, 39.80, -89.64),
|
||||
candidate(QStringLiteral("Springfield"), QStringLiteral("Georgia"), QStringLiteral("United States"), QStringLiteral("US"), QStringLiteral("PPL"), 2700, 32.37, -81.31),
|
||||
};
|
||||
|
||||
const QVector<WeatherLocationCandidate> selected = WeatherLocationResolver::selectRelevantCandidates(
|
||||
candidates,
|
||||
WeatherLocationResolver::resolveSearch(QStringLiteral("Springfield")));
|
||||
QA_EXPECT_EQ(selected.size(), 2);
|
||||
QA_EXPECT(WeatherLocationResolver::hasAmbiguity(selected));
|
||||
}
|
||||
|
||||
QA_TEST(WeatherLocationResolver, AppliesExplicitCountryFilter)
|
||||
{
|
||||
const QVector<WeatherLocationCandidate> candidates = {
|
||||
candidate(QStringLiteral("Tokyo"), QStringLiteral("Tokyo"), QStringLiteral("Japan"), QStringLiteral("JP"), QStringLiteral("PPLC"), 8300000, 35.68, 139.69),
|
||||
candidate(QStringLiteral("Tokyo"), QStringLiteral("Texas"), QStringLiteral("United States"), QStringLiteral("US"), QStringLiteral("PPL"), 1000, 33.90, -102.50),
|
||||
};
|
||||
|
||||
const QVector<WeatherLocationCandidate> selected = WeatherLocationResolver::selectRelevantCandidates(
|
||||
candidates,
|
||||
WeatherLocationResolver::resolveSearch(QStringLiteral("日本东京")));
|
||||
QA_EXPECT_EQ(selected.size(), 1);
|
||||
QA_EXPECT_EQ(selected.first().countryCode, QStringLiteral("JP"));
|
||||
}
|
||||
|
||||
QA_TEST(WeatherLocationResolver, ParsesBothIpLocationSchemas)
|
||||
{
|
||||
const QByteArray ipApiPayload = QByteArrayLiteral(
|
||||
R"({"city":"Xi'an","region":"Shaanxi","country_name":"China","latitude":34.26,"longitude":108.94,"timezone":"Asia/Shanghai"})");
|
||||
const QByteArray ipWhoIsPayload = QByteArrayLiteral(
|
||||
R"({"success":true,"city":"Tokyo","region":"Tokyo","country":"Japan","latitude":35.68,"longitude":139.69,"timezone":{"id":"Asia/Tokyo"}})");
|
||||
|
||||
WeatherLocation ipApiLocation;
|
||||
WeatherLocation ipWhoIsLocation;
|
||||
QA_EXPECT(WeatherLocationResolver::parseIpLocationResponse(
|
||||
ipApiPayload,
|
||||
WeatherIpLocationProvider::IpApiCo,
|
||||
&ipApiLocation));
|
||||
QA_EXPECT(WeatherLocationResolver::parseIpLocationResponse(
|
||||
ipWhoIsPayload,
|
||||
WeatherIpLocationProvider::IpWhoIs,
|
||||
&ipWhoIsLocation));
|
||||
QA_EXPECT_EQ(ipApiLocation.cityName, QStringLiteral("Xi'an"));
|
||||
QA_EXPECT_EQ(ipApiLocation.countryName, QStringLiteral("China"));
|
||||
QA_EXPECT_EQ(ipApiLocation.timezone, QStringLiteral("Asia/Shanghai"));
|
||||
QA_EXPECT_EQ(ipWhoIsLocation.cityName, QStringLiteral("Tokyo"));
|
||||
QA_EXPECT_EQ(ipWhoIsLocation.countryName, QStringLiteral("Japan"));
|
||||
QA_EXPECT_EQ(ipWhoIsLocation.timezone, QStringLiteral("Asia/Tokyo"));
|
||||
}
|
||||
|
||||
QA_TEST(WeatherLocationResolver, RejectsBrokenIpLocationResponses)
|
||||
{
|
||||
WeatherLocation location;
|
||||
QA_EXPECT(!WeatherLocationResolver::parseIpLocationResponse(
|
||||
QByteArrayLiteral("{broken"),
|
||||
WeatherIpLocationProvider::IpApiCo,
|
||||
&location));
|
||||
QA_EXPECT(!WeatherLocationResolver::parseIpLocationResponse(
|
||||
QByteArrayLiteral(R"({"success":false,"message":"rate limited"})"),
|
||||
WeatherIpLocationProvider::IpWhoIs,
|
||||
&location));
|
||||
QA_EXPECT(!WeatherLocationResolver::parseIpLocationResponse(
|
||||
QByteArrayLiteral(R"({"city":"Tokyo","latitude":35.68})"),
|
||||
WeatherIpLocationProvider::IpWhoIs,
|
||||
&location));
|
||||
}
|
||||
|
||||
QA_TEST(WeatherFormatter, MapsWeatherCodesAndWindDirections)
|
||||
{
|
||||
QA_EXPECT_EQ(WeatherSummaryFormatter::weatherCodeText(0), QStringLiteral("晴"));
|
||||
|
||||
Reference in New Issue
Block a user