mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-29 21:53:43 +00:00
feat(ui): 服务器表单输入限制与端口十进制草稿
issue #81 第一部分:手动表单产品上限(名称 128、地址 255、密码 1024 用户可见字符;插件参数 65,536 UTF-8 字节;端口最多五位十进制数字)。 校验收进 ServerFormFields 唯一入口 validateForSubmit(),字段错误 typed 呈现、修正即清除、首错按栅格顺序定位;validateName/hasNameError 退役。端口改十进制字符串草稿,draft 在草稿不可解析时为 nil、仅在 校验通过后消费,数值等价改写不算变更——无效输入不再悄悄提交旧绑定 值。与装载基线逐字相同的超长字段放行(历史数据兼容);导入与订阅 路径不受限。新错误文案入 Localizable.xcstrings(中英)。
This commit is contained in:
@@ -12,6 +12,61 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"名称最多 %lld 个字符": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Name must be at most %lld characters"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"密码最多 %lld 个字符": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Password must be at most %lld characters"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"插件参数最多 %lld 个字节": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Plugin options must be at most %lld bytes"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"服务器地址最多 %lld 个字符": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Server address must be at most %lld characters"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"端口必须是 1–65535 的数字(最多 %lld 位)": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Port must be a number from 1 to 65535 (at most %lld digits)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"请输入服务器名称": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
||||
@@ -16,7 +16,7 @@ struct NewServerSheet: View {
|
||||
/// 端口 8388、加密 aes-256-gcm、插件「无」。
|
||||
@StateObject private var fields = ServerFormFields.newForm()
|
||||
@State private var isSubmitting = false
|
||||
@FocusState private var nameFocused: Bool
|
||||
@FocusState private var fieldFocus: ServerFormField?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
@@ -25,7 +25,7 @@ struct NewServerSheet: View {
|
||||
|
||||
ServerFormFieldsGrid(
|
||||
fields: fields, plugin: pluginSection, isEditable: !isSubmitting,
|
||||
nameFocus: $nameFocused)
|
||||
fieldFocus: $fieldFocus)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
@@ -36,7 +36,7 @@ struct NewServerSheet: View {
|
||||
}
|
||||
.disabled(isSubmitting)
|
||||
.interactiveDismissDisabled(isSubmitting)
|
||||
.defaultFocus($nameFocused, true)
|
||||
.defaultFocus($fieldFocus, .name)
|
||||
.padding(20)
|
||||
.frame(minWidth: 520, minHeight: 400)
|
||||
}
|
||||
@@ -55,11 +55,10 @@ struct NewServerSheet: View {
|
||||
|
||||
private func create() {
|
||||
guard !isSubmitting else { return }
|
||||
guard fields.validateName() else {
|
||||
nameFocused = true
|
||||
guard fields.validateForSubmit(), let draft = fields.draft else {
|
||||
fieldFocus = fields.firstErrorField
|
||||
return
|
||||
}
|
||||
let draft = fields.draft
|
||||
isSubmitting = true
|
||||
Task {
|
||||
defer { isSubmitting = false }
|
||||
|
||||
@@ -15,7 +15,7 @@ struct ServerDetailView: View {
|
||||
@StateObject private var fields = ServerFormFields()
|
||||
@State private var isSubmitting = false
|
||||
@State private var loadedServerID: NodeID?
|
||||
@FocusState private var nameFocused: Bool
|
||||
@FocusState private var fieldFocus: ServerFormField?
|
||||
// 分享/二维码状态由同 module 的 ServerDetailView+Share.swift 扩展驱动。
|
||||
@State var showQR = false
|
||||
@State var qrImage: NSImage?
|
||||
@@ -110,7 +110,7 @@ struct ServerDetailView: View {
|
||||
}
|
||||
ServerFormFieldsGrid(
|
||||
fields: fields, plugin: formState?.plugin, isEditable: isEditable,
|
||||
nameFocus: $nameFocused
|
||||
fieldFocus: $fieldFocus
|
||||
)
|
||||
.padding(.top, 20)
|
||||
|
||||
@@ -178,12 +178,11 @@ struct ServerDetailView: View {
|
||||
|
||||
private func save() {
|
||||
guard !isSubmitting, fields.hasChanges else { return }
|
||||
guard fields.validateName() else {
|
||||
nameFocused = true
|
||||
guard fields.validateForSubmit(), let draft = fields.draft else {
|
||||
fieldFocus = fields.firstErrorField
|
||||
return
|
||||
}
|
||||
let id = serverID
|
||||
let draft = fields.draft
|
||||
isSubmitting = true
|
||||
Task {
|
||||
defer { isSubmitting = false }
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 表单字段标识:字段级校验错误的挂靠位与首错定位序(表单栅格顺序)。
|
||||
enum ServerFormField: Hashable {
|
||||
case name
|
||||
case address
|
||||
case port
|
||||
case password
|
||||
case pluginOptions
|
||||
|
||||
static let focusOrder: [ServerFormField] = [.name, .address, .port, .password, .pluginOptions]
|
||||
}
|
||||
|
||||
/// 字段级校验错误(issue #81):只携带字段、原因与上限,不回显字段内容;
|
||||
/// 文案归呈现层(`ServerFormFieldsGrid`)。
|
||||
enum ServerFormFieldError: Equatable {
|
||||
/// 名称必填(原有规则并入统一校验入口)。
|
||||
case missingName
|
||||
/// 用户可见字符(Swift `Character`)超上限。
|
||||
case tooManyCharacters(limit: Int)
|
||||
/// UTF-8 字节超上限(插件参数按最终字符串的字节数计量)。
|
||||
case tooManyBytes(limit: Int)
|
||||
/// 端口草稿为空、含非十进制数字、超过五位或落在 1–65535 之外。
|
||||
case invalidPort
|
||||
}
|
||||
|
||||
/// 服务器表单草稿(UI 持有):连接字段草稿状态与「字段 ↔ ServerEditForm/
|
||||
/// ServerEditDraft 命令」的映射只在此写一次;新建与编辑两个表单面各自
|
||||
/// `@StateObject` 持有一份,经 `load(from:)` 装载、`draft` 提交,第三个表单
|
||||
@@ -7,39 +31,82 @@ import SwiftUI
|
||||
/// 与插件区 facts 由 caller 注入。
|
||||
@MainActor
|
||||
final class ServerFormFields: ObservableObject {
|
||||
@Published var address = ""
|
||||
@Published var port = 8388
|
||||
/// 手动表单的产品输入上限(issue #81):不宣称是 DNS、Shadowsocks 或
|
||||
/// SIP003 协议的最大长度;导入、订阅刷新与既有保存数据不执行这些上限。
|
||||
static let nameCharacterLimit = 128
|
||||
static let addressCharacterLimit = 255
|
||||
static let portDigitLimit = 5
|
||||
static let passwordCharacterLimit = 1_024
|
||||
static let pluginOptionsUTF8Limit = 65_536
|
||||
|
||||
@Published var address = "" { didSet { clearError(.address) } }
|
||||
/// 端口十进制编辑草稿:提交前经 `validateForSubmit()` 验证,不用数值绑定
|
||||
/// 以免转换失败时悄悄提交旧绑定值(issue #81)。
|
||||
@Published var portText = "" { didSet { clearError(.port) } }
|
||||
@Published var encryptionMethod = ""
|
||||
@Published var password = ""
|
||||
@Published var password = "" { didSet { clearError(.password) } }
|
||||
@Published var remark = "" {
|
||||
didSet {
|
||||
if !nameIsEmpty { hasNameError = false }
|
||||
if !nameIsEmpty { clearError(.name) }
|
||||
}
|
||||
}
|
||||
@Published var pluginChoice: PluginSelection = .none
|
||||
@Published var pluginOptionsText = ""
|
||||
@Published var pluginOptionsText = "" { didSet { clearError(.pluginOptions) } }
|
||||
@Published var showPassword = false
|
||||
@Published private(set) var hasNameError = false
|
||||
@Published private(set) var fieldErrors: [ServerFormField: ServerFormFieldError] = [:]
|
||||
@Published private var savedDraft: ServerEditDraft?
|
||||
|
||||
/// 变更检测按提交草稿比较;端口在草稿可解析时按数值比较(如前导零改写
|
||||
/// 不算变更),不可解析视为已变更,保证非法输入后保存入口仍可点击并在
|
||||
/// 提交时得到行内错误,而不是按钮死锁。
|
||||
var hasChanges: Bool {
|
||||
guard let savedDraft else { return false }
|
||||
return draft != savedDraft
|
||||
draft != savedDraft
|
||||
}
|
||||
|
||||
private var nameIsEmpty: Bool {
|
||||
remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
/// 提交时才提示名称错误;后台仍独立执行完整校验。
|
||||
func validateName() -> Bool {
|
||||
hasNameError = nameIsEmpty
|
||||
return !hasNameError
|
||||
/// 提交前的全量校验(issue #81):名称必填与五字段输入上限;与装载基线
|
||||
/// 逐字相同的超限字段放行(未修改的历史超长值不阻塞其他变更保存)。
|
||||
/// 错误逐字段发布,重新编辑对应字段即清除;首次装载不显示。
|
||||
@discardableResult
|
||||
func validateForSubmit() -> Bool {
|
||||
var errors: [ServerFormField: ServerFormFieldError] = [:]
|
||||
if nameIsEmpty {
|
||||
errors[.name] = .missingName
|
||||
} else {
|
||||
errors[.name] = characterLimitError(
|
||||
raw: remark, measured: remark.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
baseline: savedDraft?.remark, limit: Self.nameCharacterLimit)
|
||||
}
|
||||
errors[.address] = characterLimitError(
|
||||
raw: address, measured: address.trimmingCharacters(in: .whitespaces),
|
||||
baseline: savedDraft?.address, limit: Self.addressCharacterLimit)
|
||||
if submittablePort == nil {
|
||||
errors[.port] = .invalidPort
|
||||
}
|
||||
errors[.password] = characterLimitError(
|
||||
raw: password, measured: password,
|
||||
baseline: savedDraft?.password, limit: Self.passwordCharacterLimit)
|
||||
if pluginChoice != .none {
|
||||
errors[.pluginOptions] = utf8LimitError(
|
||||
raw: pluginOptionsText, baseline: savedDraft?.pluginOptions,
|
||||
limit: Self.pluginOptionsUTF8Limit)
|
||||
}
|
||||
fieldErrors = errors
|
||||
return errors.isEmpty
|
||||
}
|
||||
|
||||
/// 首个待修正字段(首错定位用);无错误时为 nil。
|
||||
var firstErrorField: ServerFormField? {
|
||||
ServerFormField.focusOrder.first { fieldErrors[$0] != nil }
|
||||
}
|
||||
|
||||
/// 新建表单的默认字段:端口 8388、加密 aes-256-gcm、插件「无」。
|
||||
static func newForm() -> ServerFormFields {
|
||||
let fields = ServerFormFields()
|
||||
fields.portText = "8388"
|
||||
fields.encryptionMethod = "aes-256-gcm"
|
||||
return fields
|
||||
}
|
||||
@@ -58,20 +125,23 @@ final class ServerFormFields: ObservableObject {
|
||||
/// 明文显示。
|
||||
func load(from state: ServerEditForm) {
|
||||
address = state.address
|
||||
port = state.port
|
||||
portText = String(state.port)
|
||||
encryptionMethod = state.encryptionMethod
|
||||
password = state.password
|
||||
remark = state.remark
|
||||
pluginChoice = state.plugin.selection
|
||||
pluginOptionsText = state.plugin.options
|
||||
showPassword = false
|
||||
hasNameError = false
|
||||
fieldErrors = [:]
|
||||
savedDraft = draft
|
||||
}
|
||||
|
||||
/// 提交载荷:配置与凭据字段作为一个逻辑变更(story 12)。
|
||||
var draft: ServerEditDraft {
|
||||
ServerEditDraft(
|
||||
/// 提交载荷:配置与凭据字段作为一个逻辑变更(story 12)。端口取自十进制
|
||||
/// 草稿;草稿不能作为提交端口时为 nil——本 getter 仅在 `validateForSubmit()`
|
||||
/// 通过后消费,无效端口不得悄悄回落到旧值(issue #81)。
|
||||
var draft: ServerEditDraft? {
|
||||
guard let port = submittablePort else { return nil }
|
||||
return ServerEditDraft(
|
||||
address: address,
|
||||
port: port,
|
||||
encryptionMethod: encryptionMethod,
|
||||
@@ -80,38 +150,74 @@ final class ServerFormFields: ObservableObject {
|
||||
plugin: pluginChoice,
|
||||
pluginOptions: pluginOptionsText)
|
||||
}
|
||||
|
||||
// MARK: - 校验实现
|
||||
|
||||
/// 端口草稿可否作为提交端口:非空、纯 ASCII 十进制数字、不超过五位且
|
||||
/// 落在 1–65535。
|
||||
private var submittablePort: Int? {
|
||||
guard !portText.isEmpty, portText.count <= Self.portDigitLimit,
|
||||
portText.allSatisfy { ("0"..."9").contains($0) },
|
||||
let port = Int(portText)
|
||||
else { return nil }
|
||||
return (1...65_535).contains(port) ? port : nil
|
||||
}
|
||||
|
||||
/// 字符上限:与基线逐字相同(未修改)的历史超长值放行;名称与地址按
|
||||
/// 提交值(现行首尾空白处理后)计量,密码不 trim、不 Unicode 归一化。
|
||||
private func characterLimitError(
|
||||
raw: String, measured: String, baseline: String?, limit: Int
|
||||
) -> ServerFormFieldError? {
|
||||
guard baseline == nil || raw != baseline else { return nil }
|
||||
return measured.count > limit ? .tooManyCharacters(limit: limit) : nil
|
||||
}
|
||||
|
||||
/// 插件参数字节上限:按最终参数字符串的 UTF-8 字节数计量(含分隔符与
|
||||
/// 转义),与基线逐字相同的未修改值放行。
|
||||
private func utf8LimitError(
|
||||
raw: String, baseline: String?, limit: Int
|
||||
) -> ServerFormFieldError? {
|
||||
guard baseline == nil || raw != baseline else { return nil }
|
||||
return raw.utf8.count > limit ? .tooManyBytes(limit: limit) : nil
|
||||
}
|
||||
|
||||
private func clearError(_ field: ServerFormField) {
|
||||
guard fieldErrors[field] != nil else { return }
|
||||
fieldErrors[field] = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 新建与编辑共享的名称优先表单;焦点由提交入口持有,校验失败时定位名称。
|
||||
/// 新建与编辑共享的名称优先表单;焦点由提交入口持有,校验失败时定位首个
|
||||
/// 待修正字段。
|
||||
struct ServerFormFieldsGrid: View {
|
||||
@ObservedObject var fields: ServerFormFields
|
||||
let plugin: PluginSectionState?
|
||||
let isEditable: Bool
|
||||
let nameFocus: FocusState<Bool>.Binding
|
||||
let fieldFocus: FocusState<ServerFormField?>.Binding
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
column("名称") {
|
||||
TextField("名称", text: $fields.remark, prompt: Text("例如:香港服务器"))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused(nameFocus)
|
||||
.focused(fieldFocus, equals: .name)
|
||||
.disabled(!isEditable)
|
||||
if fields.hasNameError {
|
||||
Text("请输入服务器名称")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
fieldErrorLabel(.name)
|
||||
}
|
||||
ServerEndpointLayout {
|
||||
column("服务器地址") {
|
||||
TextField("服务器地址", text: $fields.address)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused(fieldFocus, equals: .address)
|
||||
.disabled(!isEditable)
|
||||
fieldErrorLabel(.address)
|
||||
}
|
||||
column("端口") {
|
||||
TextField("端口", value: $fields.port, format: .number.grouping(.never))
|
||||
TextField("端口", text: $fields.portText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused(fieldFocus, equals: .port)
|
||||
.disabled(!isEditable)
|
||||
fieldErrorLabel(.port)
|
||||
}
|
||||
}
|
||||
column("加密方式") {
|
||||
@@ -134,6 +240,7 @@ struct ServerFormFieldsGrid: View {
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textContentType(nil)
|
||||
.autocorrectionDisabled()
|
||||
.focused(fieldFocus, equals: .password)
|
||||
.disabled(!isEditable)
|
||||
Button {
|
||||
fields.showPassword.toggle()
|
||||
@@ -144,13 +251,16 @@ struct ServerFormFieldsGrid: View {
|
||||
.help(fields.showPassword ? "隐藏密码" : "显示密码")
|
||||
.accessibilityLabel(fields.showPassword ? "隐藏密码" : "显示密码")
|
||||
}
|
||||
fieldErrorLabel(.password)
|
||||
}
|
||||
column("插件") {
|
||||
ServerPluginSection(
|
||||
selection: $fields.pluginChoice,
|
||||
optionsText: $fields.pluginOptionsText,
|
||||
plugin: plugin,
|
||||
isEditable: isEditable)
|
||||
isEditable: isEditable,
|
||||
optionsError: fields.fieldErrors[.pluginOptions],
|
||||
optionsFocus: fieldFocus)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,6 +276,36 @@ struct ServerFormFieldsGrid: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
/// 字段行内错误:只呈现字段、原因与上限,不回显字段内容(issue #81)。
|
||||
@ViewBuilder
|
||||
private func fieldErrorLabel(_ field: ServerFormField) -> some View {
|
||||
if let error = fields.fieldErrors[field] {
|
||||
Text(errorText(error, field: field))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func errorText(_ error: ServerFormFieldError, field: ServerFormField)
|
||||
-> LocalizedStringKey
|
||||
{
|
||||
switch error {
|
||||
case .missingName:
|
||||
return "请输入服务器名称"
|
||||
case .tooManyCharacters(let limit):
|
||||
switch field {
|
||||
case .name: return "名称最多 \(limit) 个字符"
|
||||
case .address: return "服务器地址最多 \(limit) 个字符"
|
||||
case .password: return "密码最多 \(limit) 个字符"
|
||||
case .port, .pluginOptions: return ""
|
||||
}
|
||||
case .tooManyBytes(let limit):
|
||||
return "插件参数最多 \(limit) 个字节"
|
||||
case .invalidPort:
|
||||
return "端口必须是 1–65535 的数字(最多 \(ServerFormFields.portDigitLimit) 位)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 扣除间距后按 4:1 分配地址和端口宽度,高度由字段自身决定。
|
||||
|
||||
@@ -8,6 +8,9 @@ struct ServerPluginSection: View {
|
||||
@Binding var optionsText: String
|
||||
let plugin: PluginSectionState?
|
||||
let isEditable: Bool
|
||||
/// 参数字段的行内校验错误(issue #81,如字节超限)。
|
||||
let optionsError: ServerFormFieldError?
|
||||
let optionsFocus: FocusState<ServerFormField?>.Binding
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -72,7 +75,13 @@ struct ServerPluginSection: View {
|
||||
prompt: Text("选择插件后填写参数(如 mode=websocket;host=…)")
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused(optionsFocus, equals: .pluginOptions)
|
||||
.disabled(!isEditable)
|
||||
if let optionsError {
|
||||
Text(optionsErrorText(optionsError))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,4 +103,12 @@ struct ServerPluginSection: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 参数错误文案:只呈现原因与上限,不回显参数内容。
|
||||
private func optionsErrorText(_ error: ServerFormFieldError) -> LocalizedStringKey {
|
||||
switch error {
|
||||
case .tooManyBytes(let limit): return "插件参数最多 \(limit) 个字节"
|
||||
case .missingName, .tooManyCharacters, .invalidPort: return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import Testing
|
||||
|
||||
@testable import ShadowsocksX_NG2
|
||||
|
||||
/// 服务器表单的手动输入上限与端口十进制草稿(issue #81):全部经表单草稿
|
||||
/// 模块的公开面观察(装载、校验、提交草稿、变更检测),不触碰内部实现。
|
||||
@MainActor
|
||||
struct ServerFormFieldLimitsTests {
|
||||
private static let overLongName = String(
|
||||
repeating: "港", count: ServerFormFields.nameCharacterLimit + 1)
|
||||
private static let overLongAddress = String(
|
||||
repeating: "a", count: ServerFormFields.addressCharacterLimit + 1)
|
||||
private static let overLongPassword = String(
|
||||
repeating: "p", count: ServerFormFields.passwordCharacterLimit + 1)
|
||||
private static let overLongOptions = String(
|
||||
repeating: "港", count: ServerFormFields.pluginOptionsUTF8Limit / 3 + 1)
|
||||
|
||||
private static func editForm(
|
||||
name: String = "服务器",
|
||||
address: String = "203.0.113.7",
|
||||
port: Int = 8388,
|
||||
password: String = "password",
|
||||
pluginOptions: String = ""
|
||||
) -> ServerEditForm {
|
||||
ServerEditForm(
|
||||
address: address,
|
||||
port: port,
|
||||
encryptionMethod: "aes-256-gcm",
|
||||
password: password,
|
||||
remark: name,
|
||||
plugin: PluginSectionState(
|
||||
selection: .managed(program: "v2ray-plugin"),
|
||||
managed: ManagedPluginCatalog.plugins,
|
||||
provided: true,
|
||||
optionsPresent: !pluginOptions.isEmpty,
|
||||
options: pluginOptions),
|
||||
isEditable: true)
|
||||
}
|
||||
|
||||
private static func loadedFields(_ form: ServerEditForm = editForm()) -> ServerFormFields {
|
||||
let fields = ServerFormFields()
|
||||
fields.load(from: form)
|
||||
return fields
|
||||
}
|
||||
|
||||
// MARK: - 端口十进制草稿
|
||||
|
||||
@Test(
|
||||
arguments: [
|
||||
("", "空输入"), ("abc", "非数字"), ("12a4", "中途字母"), ("123456", "六位数字"),
|
||||
("0", "零"), ("65536", "超范围"), ("-1", "负号"), (" 8388", "带空格"),
|
||||
("8388", "全角数字"),
|
||||
])
|
||||
func invalidPortDraftsBlockSubmissionAndNeverSubmitStaleValues(_ text: String, _ label: String) {
|
||||
let fields = Self.loadedFields()
|
||||
fields.portText = text
|
||||
#expect(!fields.validateForSubmit(), "\(label)")
|
||||
#expect(fields.fieldErrors[.port] == .invalidPort, "\(label)")
|
||||
#expect(fields.draft == nil, "无效端口不得回落到旧绑定值:\(label)")
|
||||
#expect(fields.hasChanges, "非法端口仍是变更,保存入口不得死锁:\(label)")
|
||||
}
|
||||
|
||||
@Test(arguments: [("1", 1), ("65535", 65_535), ("8388", 8388), ("08388", 8388)])
|
||||
func validPortDraftsSubmitParsedValues(_ text: String, expected: Int) {
|
||||
let fields = Self.loadedFields()
|
||||
fields.portText = text
|
||||
#expect(fields.validateForSubmit())
|
||||
#expect(fields.draft?.port == expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
func numericallyEqualPortRewriteIsNotAChange() {
|
||||
let fields = Self.loadedFields()
|
||||
fields.portText = "08388"
|
||||
#expect(!fields.hasChanges)
|
||||
fields.portText = "8389"
|
||||
#expect(fields.hasChanges)
|
||||
}
|
||||
|
||||
// MARK: - 字符上限(Swift Character 计量)
|
||||
|
||||
@Test
|
||||
func nameLimitCountsCharactersIncludingComposedSequences() {
|
||||
let fields = Self.loadedFields()
|
||||
fields.remark = String(repeating: "港", count: ServerFormFields.nameCharacterLimit)
|
||||
#expect(fields.validateForSubmit())
|
||||
fields.remark = Self.overLongName
|
||||
#expect(!fields.validateForSubmit())
|
||||
#expect(
|
||||
fields.fieldErrors[.name] == .tooManyCharacters(limit: ServerFormFields.nameCharacterLimit))
|
||||
|
||||
// 组合字符序列按用户可见字符计数:e + U+0301 是一个 Character。
|
||||
fields.remark = String(repeating: "e\u{301}", count: ServerFormFields.nameCharacterLimit)
|
||||
#expect(fields.validateForSubmit())
|
||||
fields.remark = String(repeating: "e\u{301}", count: ServerFormFields.nameCharacterLimit + 1)
|
||||
#expect(!fields.validateForSubmit())
|
||||
}
|
||||
|
||||
@Test
|
||||
func nameAndAddressLimitsMeasurePreparedValuesAfterExistingTrimming() {
|
||||
let fields = Self.loadedFields()
|
||||
// 名称/地址沿用首尾空白处理后计量:首尾空白不计入上限。
|
||||
fields.remark = " " + String(repeating: "港", count: ServerFormFields.nameCharacterLimit) + " "
|
||||
#expect(fields.validateForSubmit())
|
||||
fields.address = " " + String(repeating: "a", count: ServerFormFields.addressCharacterLimit)
|
||||
#expect(fields.validateForSubmit())
|
||||
// 去掉空白后仍超一位才越界。
|
||||
fields.remark = " " + Self.overLongName
|
||||
#expect(!fields.validateForSubmit())
|
||||
fields.remark = "服务器"
|
||||
fields.address = Self.overLongAddress
|
||||
#expect(!fields.validateForSubmit())
|
||||
#expect(
|
||||
fields.fieldErrors[.address]
|
||||
== .tooManyCharacters(limit: ServerFormFields.addressCharacterLimit))
|
||||
}
|
||||
|
||||
@Test
|
||||
func passwordLimitDoesNotTrimOrNormalize() {
|
||||
let fields = Self.loadedFields()
|
||||
fields.password = String(repeating: "p", count: ServerFormFields.passwordCharacterLimit)
|
||||
#expect(fields.validateForSubmit(), "恰好达标")
|
||||
// 密码不 trim、不归一化:尾随空格计入长度,1025 位即越界。
|
||||
fields.password = String(repeating: "p", count: ServerFormFields.passwordCharacterLimit) + " "
|
||||
#expect(!fields.validateForSubmit())
|
||||
fields.password = Self.overLongPassword
|
||||
#expect(!fields.validateForSubmit())
|
||||
#expect(
|
||||
fields.fieldErrors[.password]
|
||||
== .tooManyCharacters(limit: ServerFormFields.passwordCharacterLimit))
|
||||
}
|
||||
|
||||
// MARK: - 插件参数字节上限(UTF-8 计量)
|
||||
|
||||
@Test
|
||||
func pluginOptionsLimitCountsUTF8BytesOfTheWholeString() {
|
||||
let fields = Self.loadedFields()
|
||||
fields.pluginOptionsText = String(
|
||||
repeating: "a", count: ServerFormFields.pluginOptionsUTF8Limit)
|
||||
#expect(fields.validateForSubmit(), "恰好 65,536 字节达标")
|
||||
fields.pluginOptionsText = String(
|
||||
repeating: "a", count: ServerFormFields.pluginOptionsUTF8Limit + 1)
|
||||
#expect(!fields.validateForSubmit())
|
||||
#expect(
|
||||
fields.fieldErrors[.pluginOptions]
|
||||
== .tooManyBytes(limit: ServerFormFields.pluginOptionsUTF8Limit))
|
||||
|
||||
// 多字节字符按字节计:21846 个「港」= 65,538 字节越界。
|
||||
fields.pluginOptionsText = String(repeating: "港", count: 21_846)
|
||||
#expect(!fields.validateForSubmit())
|
||||
}
|
||||
|
||||
@Test
|
||||
func hiddenOptionsDraftDoesNotBlockSaveWhenPluginIsNone() {
|
||||
let fields = Self.loadedFields()
|
||||
fields.pluginChoice = .none
|
||||
fields.pluginOptionsText = Self.overLongOptions
|
||||
#expect(fields.validateForSubmit(), "插件「无」时隐藏参数草稿不阻塞保存")
|
||||
}
|
||||
|
||||
// MARK: - 超长基线兼容
|
||||
|
||||
@Test
|
||||
func unmodifiedOverLongBaselineFieldsDoNotBlockSavingOtherChanges() {
|
||||
let fields = Self.loadedFields(
|
||||
Self.editForm(
|
||||
name: Self.overLongName, address: Self.overLongAddress,
|
||||
password: Self.overLongPassword, pluginOptions: Self.overLongOptions))
|
||||
#expect(fields.validateForSubmit(), "未修改的历史超长字段放行")
|
||||
#expect(fields.draft != nil)
|
||||
|
||||
// 只改名称(合规新值),其余超长基线字段继续放行。
|
||||
fields.remark = "新名称"
|
||||
#expect(fields.validateForSubmit())
|
||||
|
||||
// 主动修改超限字段后必须满足上限。
|
||||
fields.address = Self.overLongAddress + "x"
|
||||
#expect(!fields.validateForSubmit())
|
||||
#expect(
|
||||
fields.fieldErrors[.address]
|
||||
== .tooManyCharacters(limit: ServerFormFields.addressCharacterLimit))
|
||||
}
|
||||
|
||||
@Test
|
||||
func baselineExemptionUsesTheLoadedSnapshotNotLiveCatalogValues() {
|
||||
let fields = Self.loadedFields(Self.editForm(name: Self.overLongName))
|
||||
#expect(fields.validateForSubmit())
|
||||
// 再次成功装载后基线刷新。
|
||||
fields.remark = "正常名称"
|
||||
fields.load(from: Self.editForm(name: Self.overLongName))
|
||||
#expect(fields.validateForSubmit(), "重载后基线恢复为超长保存值,未修改即放行")
|
||||
}
|
||||
|
||||
// MARK: - 错误呈现时机
|
||||
|
||||
@Test
|
||||
func firstOpenShowsNoErrorsAndErrorsClearOnFieldEdit() {
|
||||
let fields = ServerFormFields.newForm()
|
||||
#expect(fields.fieldErrors.isEmpty, "首次打开不显示校验错误")
|
||||
#expect(!fields.validateForSubmit(), "新建面名称为空仍拒绝提交(原有必填规则)")
|
||||
#expect(fields.firstErrorField == .name)
|
||||
#expect(fields.fieldErrors[.name] == .missingName)
|
||||
|
||||
fields.remark = "有名称"
|
||||
#expect(fields.fieldErrors[.name] == nil, "重新编辑对应字段即清除")
|
||||
|
||||
fields.portText = "abc"
|
||||
#expect(!fields.validateForSubmit())
|
||||
fields.portText = "8388"
|
||||
#expect(fields.fieldErrors[.port] == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func firstErrorFieldFollowsGridOrder() {
|
||||
let fields = Self.loadedFields()
|
||||
fields.portText = "0"
|
||||
fields.remark = ""
|
||||
fields.password = Self.overLongPassword
|
||||
#expect(!fields.validateForSubmit())
|
||||
#expect(fields.firstErrorField == .name, "名称 → 地址 → 端口 → 密码 → 参数")
|
||||
}
|
||||
|
||||
@Test
|
||||
func loadedEditFormShowsNoErrorsUntilValidated() {
|
||||
let fields = Self.loadedFields(Self.editForm(name: Self.overLongName))
|
||||
#expect(fields.fieldErrors.isEmpty)
|
||||
#expect(fields.validateForSubmit())
|
||||
#expect(fields.fieldErrors.isEmpty)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user