应用内下载 octagram 模型较慢时,设置页每个模型行提供「浏览器下载」直链, 下载后用「导入」选中 .gram 文件:后台复制进模型目录,与应用内下载走同一套 格式校验与原子落位,原文件不动。导入与下载共用单槽。 帮助页新增网络代理(跟随系统 / 直连 / 自定义 HTTP 代理),新增 [network] 配置段。云输入、模型下载、AI 联想、翻译、语音识别与润色、凭证测试统一经 NetworkProxy 选择代理,改动即时生效。WinHTTP 的「跟随系统」由 DEFAULT_PROXY 改为 AUTOMATIC_PROXY,真正读取用户的系统代理与 PAC。 契约新增 collocationModelImport 消息;旧设置宿主补上 openExternalUrl。
279 lines
13 KiB
C++
279 lines
13 KiB
C++
// octagram 模型目录(catalog)与激活链路的测试:快照集合形状、modelId 下载/删除守卫、
|
|
// 激活写入与「空激活 = 未选择任何模型」的解析语义。消息 schema 的形状(collocationModel
|
|
// Download/Delete 带 modelId)由 webview_contract 的 fixtures 覆盖,这里钉 Server 侧行为。
|
|
#include "tests/includes/test_framework.h"
|
|
#include "src/config/ime_config.h"
|
|
#include "src/session/engine_input_session.h"
|
|
#include "src/settings/collocation_model.h"
|
|
|
|
#include <Windows.h>
|
|
#include <cstdint>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <string>
|
|
#include <system_error>
|
|
|
|
namespace
|
|
{
|
|
// 环境变量隔离:进入时保存原值,退出时恢复(没有原值就删除)。
|
|
// 读写必须走 CRT 一侧:data_directory() 经 _wdupenv_s 读 CRT 环境副本,Win32 的
|
|
// SetEnvironmentVariableW 对它不可见(UCRT 只在 _wputenv_s 时同步缓存)——这是
|
|
// test_custom_shuangpin 同款写法的原因。
|
|
class ScopedEnv
|
|
{
|
|
public:
|
|
ScopedEnv(const wchar_t *name, const std::wstring &value) : name_(name)
|
|
{
|
|
wchar_t *previous = nullptr;
|
|
size_t size = 0;
|
|
if (_wdupenv_s(&previous, &size, name) == 0 && previous != nullptr)
|
|
{
|
|
had_previous_ = true;
|
|
previous_ = previous;
|
|
free(previous);
|
|
}
|
|
_wputenv_s(name, value.c_str());
|
|
}
|
|
~ScopedEnv()
|
|
{
|
|
restore();
|
|
}
|
|
|
|
// 显式还原(幂等):拆卸时环境变量必须先于 InitImeConfig() 还原,g_config_path 才能
|
|
// 重算回真实位置;成员析构阶段会再调一次,靠标记跳过。
|
|
void restore()
|
|
{
|
|
if (restored_)
|
|
{
|
|
return;
|
|
}
|
|
restored_ = true;
|
|
// CRT 语义:空值即删除该变量。
|
|
_wputenv_s(name_.c_str(), had_previous_ ? previous_.c_str() : L"");
|
|
}
|
|
|
|
ScopedEnv(const ScopedEnv &) = delete;
|
|
ScopedEnv &operator=(const ScopedEnv &) = delete;
|
|
|
|
private:
|
|
std::wstring name_;
|
|
std::wstring previous_;
|
|
bool had_previous_ = false;
|
|
bool restored_ = false;
|
|
};
|
|
|
|
// 一次性数据根 + 配置根。模型目录经 data_directory() 读环境变量(每次调用都读,切换
|
|
// 立即生效);配置 setter 走 g_config_path,需要 InitImeConfig() 在环境变量就位后重算
|
|
// ——这是 test_config_non_ascii_path 验证过的顺序。
|
|
class ScopedCollocationEnvironment
|
|
{
|
|
public:
|
|
ScopedCollocationEnvironment()
|
|
: root_(std::filesystem::temp_directory_path() /
|
|
(L"msime-collocation-model-test-" + std::to_wstring(GetCurrentProcessId()))),
|
|
local_app_data_(L"LOCALAPPDATA", root_.wstring()),
|
|
config_dir_(L"METASEQUOIA_IME_CONFIG_DIR", (root_ / L"metasequoiaime").wstring()),
|
|
data_dir_(L"METASEQUOIA_IME_DATA_DIR", (root_ / L"metasequoiaime").wstring())
|
|
{
|
|
std::error_code ec;
|
|
std::filesystem::remove_all(root_, ec);
|
|
std::filesystem::create_directories(root_ / L"metasequoiaime", ec);
|
|
std::filesystem::copy_file(MSIME_DEFAULT_CONFIG_PATH, root_ / L"metasequoiaime" / L"config.default.toml",
|
|
std::filesystem::copy_options::overwrite_existing, ec);
|
|
REQUIRE(!ec);
|
|
InitImeConfig();
|
|
}
|
|
~ScopedCollocationEnvironment()
|
|
{
|
|
std::error_code ec;
|
|
std::filesystem::remove_all(root_, ec);
|
|
// 先还原环境变量再重算 g_config_path:配置路径是进程级缓存,悬在已删除的测试根上
|
|
// 会让本测试之后的所有配置写入失败(顺序约定同 test_config_non_ascii_path)。
|
|
data_dir_.restore();
|
|
config_dir_.restore();
|
|
local_app_data_.restore();
|
|
InitImeConfig();
|
|
}
|
|
ScopedCollocationEnvironment(const ScopedCollocationEnvironment &) = delete;
|
|
ScopedCollocationEnvironment &operator=(const ScopedCollocationEnvironment &) = delete;
|
|
|
|
std::filesystem::path models_dir() const
|
|
{
|
|
return root_ / L"metasequoiaime" / L"models";
|
|
}
|
|
|
|
private:
|
|
std::filesystem::path root_;
|
|
ScopedEnv local_app_data_;
|
|
ScopedEnv config_dir_;
|
|
ScopedEnv data_dir_;
|
|
};
|
|
|
|
// 在数据根里放一个「已落位」的模型包。落位只在格式校验之后发生,所以查询侧把文件存在
|
|
// 直接当 ready;测试里写几个字节即可驱动同一条判定,不需要真实 .gram。
|
|
void SeedModelFile(const std::filesystem::path &models_dir, const std::wstring &id)
|
|
{
|
|
std::error_code ec;
|
|
std::filesystem::create_directories(models_dir / id, ec);
|
|
REQUIRE(!ec);
|
|
std::ofstream output(models_dir / id / (id + L".gram"), std::ios::binary | std::ios::trunc);
|
|
REQUIRE(static_cast<bool>(output));
|
|
output << "gram";
|
|
}
|
|
} // namespace
|
|
|
|
TEST_CASE(collocation_catalog_lists_four_builtin_models)
|
|
{
|
|
const auto &catalog = collocation::Catalog();
|
|
REQUIRE_EQ(catalog.size(), static_cast<std::size_t>(4));
|
|
// 次序即设置页展示次序;首条目是推荐包(仅展示身份,不承载默认语义)。
|
|
REQUIRE_EQ(std::string(catalog[0].id), std::string(collocation::kRecommendedModelId));
|
|
REQUIRE_EQ(std::string(catalog[1].id), std::string("zh-hans-t-essay-bgw"));
|
|
REQUIRE_EQ(std::string(catalog[2].id), std::string("zh-hans-t-essay-bgw-compact"));
|
|
REQUIRE_EQ(std::string(catalog[3].id), std::string("zh-moqi"));
|
|
// 每个条目都要带齐下载直链与展示字段;zh-moqi 的许可状态必须如实标注
|
|
// (构建链未声明许可),NOTICE 与设置页都从这里取词。
|
|
for (const auto &entry : catalog)
|
|
{
|
|
REQUIRE(entry.host != nullptr && *entry.host != L'\0');
|
|
REQUIRE(entry.path != nullptr && entry.path[0] == L'/');
|
|
REQUIRE(entry.size_hint != nullptr && *entry.size_hint != '\0');
|
|
REQUIRE(entry.license != nullptr && *entry.license != '\0');
|
|
REQUIRE(entry.badge != nullptr && *entry.badge != '\0');
|
|
}
|
|
REQUIRE(std::string(catalog[3].license_note).find("构建链未声明许可") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE(collocation_badge_is_per_model_and_falls_back_to_octagram_name)
|
|
{
|
|
// 候选窗的整句来源标签按模型包各自声明,不靠 id 前缀猜:万象〔万象〕、八股文两个包
|
|
// 都〔八股〕、白霜〔墨奇〕。
|
|
REQUIRE_EQ(std::string(collocation::BadgeForModel(collocation::kRecommendedModelId)), std::string("万象"));
|
|
REQUIRE_EQ(std::string(collocation::BadgeForModel("zh-hans-t-essay-bgw")), std::string("八股"));
|
|
REQUIRE_EQ(std::string(collocation::BadgeForModel("zh-hans-t-essay-bgw-compact")), std::string("八股"));
|
|
REQUIRE_EQ(std::string(collocation::BadgeForModel("zh-moqi")), std::string("墨奇"));
|
|
// 目录外手填的 id(自备模型)落回格式通称,不冒充任何一家。
|
|
REQUIRE_EQ(std::string(collocation::BadgeForModel("not-in-catalog")), std::string("八股"));
|
|
REQUIRE_EQ(std::string(collocation::BadgeForModel("")), std::string("八股"));
|
|
}
|
|
|
|
TEST_CASE(collocation_statuses_cover_catalog_and_follow_disk)
|
|
{
|
|
ScopedCollocationEnvironment env;
|
|
auto statuses = collocation::GetModelStatuses();
|
|
// 快照集合形状:逐目录条目给出一项,不多不少,未下载即 absent。
|
|
REQUIRE_EQ(statuses.size(), collocation::Catalog().size());
|
|
for (const auto &entry : collocation::Catalog())
|
|
{
|
|
REQUIRE(statuses.count(entry.id) == 1);
|
|
REQUIRE_EQ(statuses[entry.id].state, std::string("absent"));
|
|
}
|
|
// 磁盘优先:文件存在即 ready(落位发生在格式校验之后),progress 顶格。
|
|
SeedModelFile(env.models_dir(), L"zh-moqi");
|
|
statuses = collocation::GetModelStatuses();
|
|
REQUIRE_EQ(statuses["zh-moqi"].state, std::string("ready"));
|
|
REQUIRE_EQ(statuses["zh-moqi"].progress, 100);
|
|
}
|
|
|
|
TEST_CASE(collocation_download_and_delete_reject_ids_outside_catalog)
|
|
{
|
|
ScopedCollocationEnvironment env;
|
|
// modelId 消息的 schema 形状由 webview_contract 的 fixtures 把守;这里钉 Server 侧
|
|
// 对未知 id 的拒绝,下载与删除走同一守卫入口,路径拼接不允许被带出 models/。
|
|
REQUIRE(!collocation::StartDownload("not-in-catalog"));
|
|
REQUIRE(!collocation::DeleteModel("not-in-catalog"));
|
|
REQUIRE(!collocation::DeleteModel("../../escape"));
|
|
// 已就绪的包不重下:幂等返回 true 且不启动下载线程(磁盘检查先于任何 I/O)。
|
|
SeedModelFile(env.models_dir(), L"zh-moqi");
|
|
REQUIRE(collocation::StartDownload("zh-moqi"));
|
|
}
|
|
|
|
TEST_CASE(collocation_activation_write_and_delete_guard)
|
|
{
|
|
ScopedCollocationEnvironment env;
|
|
// 激活写入:setter 落盘并同步全局。空串 = 未选择任何模型(没有默认/回退包)。
|
|
REQUIRE(SetConfiguredAssocSentenceCollocationModel("zh-moqi"));
|
|
REQUIRE_EQ(GetConfiguredAssocSentenceCollocationModel(), std::string("zh-moqi"));
|
|
REQUIRE(SetConfiguredAssocSentenceCollocationModel(""));
|
|
REQUIRE(GetConfiguredAssocSentenceCollocationModel().empty());
|
|
|
|
// 未选择时任何包都可删:目录条目没有受保护的目标。
|
|
SeedModelFile(env.models_dir(), L"wanxiang-lts-zh-hans");
|
|
REQUIRE(collocation::DeleteModel(collocation::kRecommendedModelId));
|
|
REQUIRE(!std::filesystem::exists(env.models_dir() / L"wanxiang-lts-zh-hans"));
|
|
|
|
// 删除当前激活的包:文件与激活一并清掉——配置指向不存在的包只会留下僵尸选中态。
|
|
SeedModelFile(env.models_dir(), L"zh-moqi");
|
|
REQUIRE(SetConfiguredAssocSentenceCollocationModel("zh-moqi"));
|
|
REQUIRE(collocation::DeleteModel("zh-moqi"));
|
|
REQUIRE(!std::filesystem::exists(env.models_dir() / L"zh-moqi"));
|
|
REQUIRE(GetConfiguredAssocSentenceCollocationModel().empty());
|
|
|
|
// 删除非激活的包不动激活值:显式激活 zh-moqi 后删推荐包,激活保持不变。
|
|
SeedModelFile(env.models_dir(), L"zh-moqi");
|
|
SeedModelFile(env.models_dir(), L"wanxiang-lts-zh-hans");
|
|
REQUIRE(SetConfiguredAssocSentenceCollocationModel("zh-moqi"));
|
|
REQUIRE(collocation::DeleteModel(collocation::kRecommendedModelId));
|
|
REQUIRE_EQ(GetConfiguredAssocSentenceCollocationModel(), std::string("zh-moqi"));
|
|
REQUIRE(std::filesystem::exists(env.models_dir() / L"zh-moqi" / L"zh-moqi.gram"));
|
|
// 还原激活状态,不把测试残留带给同进程的后续用例。
|
|
REQUIRE(SetConfiguredAssocSentenceCollocationModel(""));
|
|
}
|
|
|
|
TEST_CASE(collocation_path_resolution_treats_empty_as_unselected)
|
|
{
|
|
ScopedCollocationEnvironment env;
|
|
// 激活值留空 = 未选择任何模型:即使推荐包就在盘上也不回退,解析返回空串,
|
|
// 调用方按整句加成全关处理。
|
|
SeedModelFile(env.models_dir(), L"wanxiang-lts-zh-hans");
|
|
REQUIRE(ResolveCollocationModelPath("").empty());
|
|
// 显式 id 只解析自己的文件,缺席仍返回空。
|
|
REQUIRE(ResolveCollocationModelPath("zh-moqi").empty());
|
|
REQUIRE(ResolveCollocationModelPath(collocation::kRecommendedModelId).find("wanxiang-lts-zh-hans") !=
|
|
std::string::npos);
|
|
}
|
|
|
|
TEST_CASE(collocation_source_url_is_https_direct_link)
|
|
{
|
|
// 设置页的「浏览器下载」与 NOTICE.md 的来源署名用同一个地址。
|
|
const auto &entry = collocation::Catalog()[0];
|
|
REQUIRE_EQ(collocation::SourceUrl(entry),
|
|
std::string("https://github.com/amzxyz/RIME-LMDG/releases/download/LTS/wanxiang-lts-zh-hans.gram"));
|
|
}
|
|
|
|
TEST_CASE(collocation_import_rejects_bad_ids_and_sources_and_validates_format)
|
|
{
|
|
ScopedCollocationEnvironment env;
|
|
const std::filesystem::path source = env.models_dir().parent_path() / L"浏览器下载.gram";
|
|
{
|
|
std::ofstream output(source, std::ios::binary | std::ios::trunc);
|
|
REQUIRE(static_cast<bool>(output));
|
|
output << "this is not an octagram model";
|
|
}
|
|
// 守卫与下载同一入口:目录外 id、缺失的源文件都同步拒绝,不起线程。
|
|
REQUIRE(!collocation::ImportModel("not-in-catalog", source));
|
|
REQUIRE(!collocation::ImportModel("zh-moqi", env.models_dir().parent_path() / L"missing.gram"));
|
|
|
|
// 格式锁对导入同样生效:坏字节进 error 态,模型目录里既不留 .gram 也不留 .part。
|
|
REQUIRE(collocation::ImportModel("zh-moqi", source));
|
|
std::string state;
|
|
for (int attempt = 0; attempt < 200; ++attempt)
|
|
{
|
|
state = collocation::GetModelStatuses()["zh-moqi"].state;
|
|
if (state != "importing")
|
|
break;
|
|
Sleep(25);
|
|
}
|
|
REQUIRE_EQ(state, std::string("error"));
|
|
REQUIRE(!std::filesystem::exists(env.models_dir() / L"zh-moqi" / L"zh-moqi.gram"));
|
|
REQUIRE(!std::filesystem::exists(env.models_dir() / L"zh-moqi" / L"zh-moqi.gram.part"));
|
|
// 用户选的原文件不被挪走。
|
|
REQUIRE(std::filesystem::exists(source));
|
|
|
|
// 已就绪的包不重做:幂等返回 true,原文件保持不变。
|
|
SeedModelFile(env.models_dir(), L"wanxiang-lts-zh-hans");
|
|
REQUIRE(collocation::ImportModel(collocation::kRecommendedModelId, source));
|
|
REQUIRE_EQ(std::filesystem::file_size(env.models_dir() / L"wanxiang-lts-zh-hans" / L"wanxiang-lts-zh-hans.gram"),
|
|
static_cast<std::uintmax_t>(4));
|
|
} |