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:
Qiu Yuzhou
2026-10-04 04:36:32 +08:00
parent 95c47195f1
commit d2d20d27a6
6 changed files with 478 additions and 38 deletions
@@ -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": {
+5 -6
View File
@@ -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 }
+4 -5
View File
@@ -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 }
+167 -27
View File
@@ -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)
}
}