mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-28 13:13:47 +00:00
feat(ui): 插件参数无损表格编辑会话
issue #81 第二部分:新 PluginOptionsDraft 会话草稿模块——带稳定行 身份的有序参数行(无值开关与空串值之分)+ 参数列表/原始文本双模式。 解析与拼装遵循受管 v2ray-plugin v1.3.2 的 SIP003 转义语义(未转义 分号分隔、首个未转义等号分键值、反斜杠转义);未修改行逐字回写原文 片段,仅显式编辑的行重新编码,既有末尾分号无结构修改时保留;不可 靠解析默认并留在原始文本模式,不强行转换。 编辑面:底部空白快速添加行(输入即提升、身份与焦点稳定)、行内 编辑与显式删除、键值⇄开关可逆且保留会话值草稿、长值「编辑值…」 独立 sheet(等宽多行、确认才写回)。未完成行(有值/开关形态却无 参数名)阻止拼装、保存与切向原始文本;模式切换与展开编辑不触发 hasChanges;原始串仍是持久化与运行时唯一载荷。CONTEXT.md 新增 Plugin parameter draft 词条;新文案入 Localizable.xcstrings(中英)。
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
- **Configuration group**: A named, ordered container that may contain server configurations and nested configuration groups. A node has at most one parent, and group membership forms an acyclic tree.
|
||||
- **Configuration group export**: A user-requested, shareable snapshot of one configuration group and all of its descendant groups and server configurations. It carries the credentials and configured plugin options needed to recreate those servers, and may include servers that are not activation candidates. It is available only when the selected subtree contains at least one server configuration.
|
||||
- **Server form draft**: The UI-held draft of one server configuration's connection fields, shared by the create and edit form faces. It loads from the edit command's resolved form state and commits as one `ServerEditDraft` command; field state, password reveal, and the form grid live in one shared module, so a new form face reuses the same load/commit pair.
|
||||
- **Plugin parameter draft**: The server form draft's session state for plugin options: an ordered list of parameter rows with stable row identities, each carrying a parameter name and an optional value that distinguishes a no-equals flag from an equals-with-empty-string value. Parsing and assembly follow the managed v2ray-plugin SIP003 escaping semantics. The raw options string remains the only payload crossing the persistence, credential, and runtime boundaries; viewing, mode switching, and unedited rows keep their original writing verbatim, and only rows the user explicitly edits are re-encoded. Recognizable syntax serves editing only and does not judge the parameter valid for the plugin.
|
||||
- **Manual group**: A configuration group owned by the user. It may contain only manually owned nodes; deleting it recursively deletes its descendants. Deletion is always user-confirmed: one confirmation for an empty manual group, and a second confirmation naming the recursive subtree scale when it is non-empty.
|
||||
- **Subscription**: A remote configuration source with a persistent opaque UUID and a fixed subscription-group identity. Its URL is a mutable endpoint/credential reference; editing the URL keeps the source identity, while deleting and creating a subscription establishes a new source identity.
|
||||
- **Subscription group**: The fixed, source-owned configuration subtree provided by one subscription. Users cannot move or structurally edit its nodes. A subscription server's user-visible name is one remote-derived value from `remarks`, falling back to its address and port when absent, empty, or whitespace-only; each refresh derives that name from the latest remote record. It has no local alias or note override.
|
||||
|
||||
@@ -12,6 +12,61 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"值": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Value"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"删除参数行": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Delete Parameter Row"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"原始文本": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Raw Text"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"参数列表": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Parameters"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"参数名": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Key"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"名称最多 %lld 个字符": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
@@ -23,6 +78,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"存在未完成的参数行:请补全参数名、清空该行或删除它": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "An unfinished parameter row exists: complete its key, clear the row, or delete it"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"密码最多 %lld 个字符": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
@@ -45,6 +111,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"无值开关": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Flag"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"服务器地址最多 %lld 个字符": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
@@ -56,6 +133,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"查看模式": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "View Mode"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"添加参数": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Add Parameter"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"确认": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Confirm"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"端口必须是 1–65535 的数字(最多 %lld 位)": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
@@ -67,6 +177,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"类型": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Kind"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"编辑值…": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Edit Value…"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"缺少参数名": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Missing key"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"请输入服务器名称": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
@@ -91,4 +234,4 @@
|
||||
}
|
||||
},
|
||||
"version": "1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// 插件参数会话草稿(issue #81,见 CONTEXT.md「Plugin parameter draft」):
|
||||
/// 带稳定行身份的有序参数行 + 原始文本双模式。解析与拼装遵循受管
|
||||
/// v2ray-plugin v1.3.2 的 SIP003 转义语义(未转义 `;` 分隔项目、第一个
|
||||
/// 未转义 `=` 分隔参数名与值、反斜杠转义后续字符);原始参数字符串仍是
|
||||
/// 持久化、凭据与运行时边界的唯一载荷,本模块状态不进入配置目录。
|
||||
@MainActor
|
||||
final class PluginOptionsDraft: ObservableObject {
|
||||
enum Mode: Hashable {
|
||||
case table
|
||||
case rawText
|
||||
}
|
||||
|
||||
/// 行的值形态:无等号的无值开关与带等号的键值(含空串值)。
|
||||
enum Kind: Hashable {
|
||||
case keyValue
|
||||
case flag
|
||||
}
|
||||
|
||||
/// 一行参数草稿:`original` 持有装载时的解析结果与原文片段,未修改的行
|
||||
/// 拼装时逐字回写。
|
||||
struct Row: Identifiable {
|
||||
let id: UUID
|
||||
var keyText: String
|
||||
var valueText: String
|
||||
var kind: Kind
|
||||
let original: ParsedItem?
|
||||
|
||||
/// 完全空白的快速添加占位行:不计入参数项、不触发变更、不序列化。
|
||||
var isBlank: Bool {
|
||||
kind == .keyValue && keyText.isEmpty && valueText.isEmpty
|
||||
}
|
||||
|
||||
/// 有值或开关形态却没有参数名的未完成草稿:阻止拼装与模式切换。
|
||||
var isUnfinished: Bool {
|
||||
keyText.isEmpty && !isBlank
|
||||
}
|
||||
|
||||
/// 与装载解析结果逐字一致(编辑后改回也算),拼装时回写原文片段。
|
||||
var isUnmodified: Bool {
|
||||
guard let original else { return false }
|
||||
guard keyText == original.key else { return false }
|
||||
switch kind {
|
||||
case .flag: return !original.hasValue
|
||||
case .keyValue: return original.hasValue && valueText == original.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ParsedItem {
|
||||
let key: String
|
||||
let hasValue: Bool
|
||||
let value: String
|
||||
let slice: String
|
||||
}
|
||||
|
||||
struct ParseOutcome {
|
||||
let items: [ParsedItem]
|
||||
let trailingSeparator: Bool
|
||||
}
|
||||
|
||||
@Published private(set) var mode: Mode = .table
|
||||
@Published private(set) var rows: [Row] = [PluginOptionsDraft.blankRow()]
|
||||
@Published var rawText = ""
|
||||
@Published private(set) var hadTrailingSeparator = false
|
||||
private var structureChanged = false
|
||||
|
||||
// MARK: - 装载与会话串
|
||||
|
||||
/// 装载(或重置):按可解析性决定初始模式,行、原文与结构标记全部重建。
|
||||
func load(_ raw: String) {
|
||||
if let outcome = Self.parse(raw) {
|
||||
mode = .table
|
||||
rows = outcome.items.map(Self.row(from:)) + [Self.blankRow()]
|
||||
hadTrailingSeparator = outcome.trailingSeparator
|
||||
} else {
|
||||
mode = .rawText
|
||||
rows = [Self.blankRow()]
|
||||
hadTrailingSeparator = false
|
||||
}
|
||||
rawText = raw
|
||||
structureChanged = false
|
||||
}
|
||||
|
||||
/// 当前会话的参数字符串:原始文本模式即其文本,列表模式拼装各行(未修改
|
||||
/// 行逐字回写)。存在未完成行(有值或开关形态却无参数名)时为 nil。
|
||||
var composedString: String? {
|
||||
switch mode {
|
||||
case .rawText: return rawText
|
||||
case .table: return assembledFromRows()
|
||||
}
|
||||
}
|
||||
|
||||
var hasUnfinishedRows: Bool {
|
||||
mode == .table && rows.contains { $0.isUnfinished }
|
||||
}
|
||||
|
||||
// MARK: - 模式切换
|
||||
|
||||
/// 切到原始文本:先验证并拼装当前行草稿;有未完成行则留在列表模式。
|
||||
@discardableResult
|
||||
func switchToRawText() -> Bool {
|
||||
guard mode == .table else { return true }
|
||||
guard let assembled = assembledFromRows() else { return false }
|
||||
rawText = assembled
|
||||
mode = .rawText
|
||||
return true
|
||||
}
|
||||
|
||||
/// 切回参数列表:重新解析当前原始文本;不可可靠解析则留在原始模式并
|
||||
/// 保留原文,不丢弃草稿。
|
||||
@discardableResult
|
||||
func switchToTable() -> Bool {
|
||||
guard mode == .rawText else { return true }
|
||||
guard let outcome = Self.parse(rawText) else { return false }
|
||||
rows = outcome.items.map(Self.row(from:)) + [Self.blankRow()]
|
||||
hadTrailingSeparator = outcome.trailingSeparator
|
||||
structureChanged = false
|
||||
mode = .table
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - 行编辑
|
||||
|
||||
func updateKey(_ text: String, of id: UUID) {
|
||||
mutateRow(id) { $0.keyText = text }
|
||||
}
|
||||
|
||||
func updateValue(_ text: String, of id: UUID) {
|
||||
mutateRow(id) { $0.valueText = text }
|
||||
}
|
||||
|
||||
/// 键值 ⇄ 无值开关:切到开关不序列化等号;切回键值恢复本会话保留的值
|
||||
/// 草稿(无则空串)。
|
||||
func setKind(_ kind: Kind, of id: UUID) {
|
||||
mutateRow(id) { $0.kind = kind }
|
||||
}
|
||||
|
||||
func deleteRow(_ id: UUID) {
|
||||
rows.removeAll { $0.id == id }
|
||||
structureChanged = true
|
||||
normalize()
|
||||
}
|
||||
|
||||
/// 「添加参数」入口语义:末尾空行即快速添加行,返回其身份供聚焦。
|
||||
var quickAddRowID: UUID {
|
||||
rows[rows.count - 1].id
|
||||
}
|
||||
|
||||
// MARK: - 行状态实现
|
||||
|
||||
private func mutateRow(_ id: UUID, _ mutate: (inout Row) -> Void) {
|
||||
guard let index = rows.firstIndex(where: { $0.id == id }) else { return }
|
||||
mutate(&rows[index])
|
||||
normalize()
|
||||
}
|
||||
|
||||
/// 末尾始终保持一条完全空白的快速添加行;在空行开始输入(参数名、值或
|
||||
/// 类型)后该行保持身份获得正式草稿地位(焦点稳定),同时补上新的空行。
|
||||
private func normalize() {
|
||||
if !(rows.last?.isBlank ?? true) {
|
||||
rows.append(Self.blankRow())
|
||||
}
|
||||
}
|
||||
|
||||
private static func blankRow() -> Row {
|
||||
Row(id: UUID(), keyText: "", valueText: "", kind: .keyValue, original: nil)
|
||||
}
|
||||
|
||||
private static func row(from item: ParsedItem) -> Row {
|
||||
Row(
|
||||
id: UUID(),
|
||||
keyText: item.key,
|
||||
valueText: item.hasValue ? item.value : "",
|
||||
kind: item.hasValue ? .keyValue : .flag,
|
||||
original: item)
|
||||
}
|
||||
|
||||
private func assembledFromRows() -> String? {
|
||||
var slices: [String] = []
|
||||
for row in rows where !row.isBlank {
|
||||
guard !row.keyText.isEmpty else { return nil }
|
||||
slices.append(encodedSlice(of: row))
|
||||
}
|
||||
var assembled = slices.joined(separator: ";")
|
||||
// 既有合法的末尾分号等非内容分隔表现,在没有对应结构修改时保留。
|
||||
if hadTrailingSeparator && !structureChanged && !slices.isEmpty {
|
||||
assembled += ";"
|
||||
}
|
||||
return assembled
|
||||
}
|
||||
|
||||
private func encodedSlice(of row: Row) -> String {
|
||||
if row.isUnmodified, let original = row.original {
|
||||
return original.slice
|
||||
}
|
||||
switch row.kind {
|
||||
case .flag:
|
||||
return Self.escaped(row.keyText, escapingEquals: false)
|
||||
case .keyValue:
|
||||
return
|
||||
Self.escaped(row.keyText, escapingEquals: true) + "="
|
||||
+ Self.escaped(row.valueText, escapingEquals: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SIP003 解析与编码(v2ray-plugin v1.3.2 args.go 语义)
|
||||
|
||||
extension PluginOptionsDraft {
|
||||
/// 解析:未转义 `;` 分隔项目,第一个未转义 `=` 分隔参数名与值,反斜杠
|
||||
/// 转义后续字符;空参数名或结尾悬空转义返回 nil——完整原文走原始文本
|
||||
/// 模式,不强行转换。
|
||||
static func parse(_ raw: String) -> ParseOutcome? {
|
||||
var items: [ParsedItem] = []
|
||||
var trailingSeparator = false
|
||||
var index = raw.startIndex
|
||||
|
||||
while index < raw.endIndex {
|
||||
let itemStart = index
|
||||
var key = ""
|
||||
var value = ""
|
||||
var hasValue = false
|
||||
var terminatedBySeparator = false
|
||||
|
||||
scan: while index < raw.endIndex {
|
||||
let character = raw[index]
|
||||
if character == "\\" {
|
||||
let escapedIndex = raw.index(after: index)
|
||||
guard escapedIndex < raw.endIndex else { return nil }
|
||||
if hasValue {
|
||||
value.append(raw[escapedIndex])
|
||||
} else {
|
||||
key.append(raw[escapedIndex])
|
||||
}
|
||||
index = raw.index(after: escapedIndex)
|
||||
continue
|
||||
}
|
||||
if character == ";" {
|
||||
terminatedBySeparator = true
|
||||
index = raw.index(after: index)
|
||||
break scan
|
||||
}
|
||||
if character == "=" && !hasValue {
|
||||
hasValue = true
|
||||
index = raw.index(after: index)
|
||||
continue
|
||||
}
|
||||
if hasValue {
|
||||
value.append(character)
|
||||
} else {
|
||||
key.append(character)
|
||||
}
|
||||
index = raw.index(after: index)
|
||||
}
|
||||
|
||||
guard !key.isEmpty else { return nil }
|
||||
let itemEnd = terminatedBySeparator ? raw.index(before: index) : index
|
||||
items.append(
|
||||
ParsedItem(
|
||||
key: key, hasValue: hasValue, value: value, slice: String(raw[itemStart..<itemEnd])))
|
||||
trailingSeparator = terminatedBySeparator && index == raw.endIndex
|
||||
}
|
||||
|
||||
return ParseOutcome(items: items, trailingSeparator: trailingSeparator)
|
||||
}
|
||||
|
||||
/// 反向编码:反斜杠、分号(及参数名中的等号)以反斜杠转义,与
|
||||
/// v2ray-plugin 的编码方向一致;其余字符(含换行、Unicode)原样保留。
|
||||
private static func escaped(_ text: String, escapingEquals: Bool) -> String {
|
||||
var result = ""
|
||||
for character in text {
|
||||
if character == "\\" || character == ";" || (escapingEquals && character == "=") {
|
||||
result.append("\\")
|
||||
}
|
||||
result.append(character)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// 表单字段标识:字段级校验错误的挂靠位与首错定位序(表单栅格顺序)。
|
||||
@@ -22,6 +23,8 @@ enum ServerFormFieldError: Equatable {
|
||||
case tooManyBytes(limit: Int)
|
||||
/// 端口草稿为空、含非十进制数字、超过五位或落在 1–65535 之外。
|
||||
case invalidPort
|
||||
/// 参数列表存在未完成行(有值或开关形态却没有参数名)。
|
||||
case unfinishedPluginOptionRow
|
||||
}
|
||||
|
||||
/// 服务器表单草稿(UI 持有):连接字段草稿状态与「字段 ↔ ServerEditForm/
|
||||
@@ -51,11 +54,23 @@ final class ServerFormFields: ObservableObject {
|
||||
}
|
||||
}
|
||||
@Published var pluginChoice: PluginSelection = .none
|
||||
@Published var pluginOptionsText = "" { didSet { clearError(.pluginOptions) } }
|
||||
/// 插件参数会话草稿(issue #81):行/模式/原文收在模块内,提交串取
|
||||
/// `composedString`;编辑参数即清除参数字段错误,变更通知转发给本类
|
||||
/// 观察者(视图只观察 ServerFormFields)。
|
||||
let pluginOptions = PluginOptionsDraft()
|
||||
private var pluginOptionsChanges: AnyCancellable?
|
||||
@Published var showPassword = false
|
||||
@Published private(set) var fieldErrors: [ServerFormField: ServerFormFieldError] = [:]
|
||||
@Published private var savedDraft: ServerEditDraft?
|
||||
|
||||
init() {
|
||||
pluginOptionsChanges = pluginOptions.objectWillChange.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.clearError(.pluginOptions)
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
/// 变更检测按提交草稿比较;端口在草稿可解析时按数值比较(如前导零改写
|
||||
/// 不算变更),不可解析视为已变更,保证非法输入后保存入口仍可点击并在
|
||||
/// 提交时得到行内错误,而不是按钮死锁。
|
||||
@@ -90,9 +105,13 @@ final class ServerFormFields: ObservableObject {
|
||||
raw: password, measured: password,
|
||||
baseline: savedDraft?.password, limit: Self.passwordCharacterLimit)
|
||||
if pluginChoice != .none {
|
||||
errors[.pluginOptions] = utf8LimitError(
|
||||
raw: pluginOptionsText, baseline: savedDraft?.pluginOptions,
|
||||
limit: Self.pluginOptionsUTF8Limit)
|
||||
if pluginOptions.hasUnfinishedRows {
|
||||
errors[.pluginOptions] = .unfinishedPluginOptionRow
|
||||
} else if let options = pluginOptions.composedString {
|
||||
errors[.pluginOptions] = utf8LimitError(
|
||||
raw: options, baseline: savedDraft?.pluginOptions,
|
||||
limit: Self.pluginOptionsUTF8Limit)
|
||||
}
|
||||
}
|
||||
fieldErrors = errors
|
||||
return errors.isEmpty
|
||||
@@ -130,17 +149,18 @@ final class ServerFormFields: ObservableObject {
|
||||
password = state.password
|
||||
remark = state.remark
|
||||
pluginChoice = state.plugin.selection
|
||||
pluginOptionsText = state.plugin.options
|
||||
pluginOptions.load(state.plugin.options)
|
||||
showPassword = false
|
||||
fieldErrors = [:]
|
||||
savedDraft = draft
|
||||
}
|
||||
|
||||
/// 提交载荷:配置与凭据字段作为一个逻辑变更(story 12)。端口取自十进制
|
||||
/// 草稿;草稿不能作为提交端口时为 nil——本 getter 仅在 `validateForSubmit()`
|
||||
/// 通过后消费,无效端口不得悄悄回落到旧值(issue #81)。
|
||||
/// 草稿、插件参数取自会话草稿拼装串;任一不能作为提交值(无效端口或
|
||||
/// 未完成参数行)时为 nil——本 getter 仅在 `validateForSubmit()` 通过后
|
||||
/// 消费,无效输入不得悄悄回落到旧值(issue #81)。
|
||||
var draft: ServerEditDraft? {
|
||||
guard let port = submittablePort else { return nil }
|
||||
guard let port = submittablePort, let options = pluginOptions.composedString else { return nil }
|
||||
return ServerEditDraft(
|
||||
address: address,
|
||||
port: port,
|
||||
@@ -148,7 +168,7 @@ final class ServerFormFields: ObservableObject {
|
||||
password: password,
|
||||
remark: remark,
|
||||
plugin: pluginChoice,
|
||||
pluginOptions: pluginOptionsText)
|
||||
pluginOptions: options)
|
||||
}
|
||||
|
||||
// MARK: - 校验实现
|
||||
@@ -256,7 +276,7 @@ struct ServerFormFieldsGrid: View {
|
||||
column("插件") {
|
||||
ServerPluginSection(
|
||||
selection: $fields.pluginChoice,
|
||||
optionsText: $fields.pluginOptionsText,
|
||||
options: fields.pluginOptions,
|
||||
plugin: plugin,
|
||||
isEditable: isEditable,
|
||||
optionsError: fields.fieldErrors[.pluginOptions],
|
||||
@@ -304,6 +324,8 @@ struct ServerFormFieldsGrid: View {
|
||||
return "插件参数最多 \(limit) 个字节"
|
||||
case .invalidPort:
|
||||
return "端口必须是 1–65535 的数字(最多 \(ServerFormFields.portDigitLimit) 位)"
|
||||
case .unfinishedPluginOptionRow:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ import SwiftUI
|
||||
|
||||
/// 服务器详情的插件区(issue #38/D10,地图 #52 票 #55):受管选择器——
|
||||
/// 「无」+ 受管列表;集外引用(Legacy 导入/订阅带入)追加显式「本版本未
|
||||
/// 提供」项使当前状态可见并原样保留。选中受管项才显示参数输入与可用性警告。
|
||||
/// 提供」项使当前状态可见并原样保留。选中受管项才显示参数编辑区与可用性
|
||||
/// 警告。订阅只读面无可编辑入口,但仍可切换参数查看模式(issue #81)。
|
||||
struct ServerPluginSection: View {
|
||||
@Binding var selection: PluginSelection
|
||||
@Binding var optionsText: String
|
||||
@ObservedObject var options: PluginOptionsDraft
|
||||
let plugin: PluginSectionState?
|
||||
let isEditable: Bool
|
||||
/// 参数字段的行内校验错误(issue #81,如字节超限)。
|
||||
/// 参数字段的行内校验错误(issue #81:字节超限或未完成行)。
|
||||
let optionsError: ServerFormFieldError?
|
||||
let optionsFocus: FocusState<ServerFormField?>.Binding
|
||||
|
||||
@@ -53,7 +54,7 @@ struct ServerPluginSection: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 选中受管项:参数输入与可用性警告。
|
||||
/// 选中受管项:参数编辑区与可用性警告。
|
||||
@ViewBuilder
|
||||
private func managedDetails(program: String, plugin: PluginSectionState) -> some View {
|
||||
if plugin.managed.contains(where: { $0.program == program }) {
|
||||
@@ -69,19 +70,11 @@ struct ServerPluginSection: View {
|
||||
Text("插件参数")
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
TextField(
|
||||
"插件参数",
|
||||
text: $optionsText,
|
||||
prompt: Text("选择插件后填写参数(如 mode=websocket;host=…)")
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused(optionsFocus, equals: .pluginOptions)
|
||||
.disabled(!isEditable)
|
||||
if let optionsError {
|
||||
Text(optionsErrorText(optionsError))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
PluginOptionsEditor(
|
||||
draft: options,
|
||||
isEditable: isEditable,
|
||||
fieldError: optionsError,
|
||||
fieldFocus: optionsFocus)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,12 +96,254 @@ struct ServerPluginSection: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 参数错误文案:只呈现原因与上限,不回显参数内容。
|
||||
private func optionsErrorText(_ error: ServerFormFieldError) -> LocalizedStringKey {
|
||||
switch error {
|
||||
case .tooManyBytes(let limit): return "插件参数最多 \(limit) 个字节"
|
||||
case .missingName, .tooManyCharacters, .invalidPort: return ""
|
||||
/// 插件参数编辑区(issue #81):参数列表 ⇄ 原始文本双模式、底部空行快速
|
||||
/// 添加、行内编辑与删除、长值独立编辑;切换查看模式不触发表单变更检测。
|
||||
private struct PluginOptionsEditor: View {
|
||||
@ObservedObject var draft: PluginOptionsDraft
|
||||
let isEditable: Bool
|
||||
let fieldError: ServerFormFieldError?
|
||||
let fieldFocus: FocusState<ServerFormField?>.Binding
|
||||
|
||||
struct EditingValue: Identifiable {
|
||||
let id: UUID
|
||||
let text: String
|
||||
}
|
||||
|
||||
/// 模式选择器的显示态:切换被未完成行拒绝时回弹到当前模式。
|
||||
@State private var displayedMode = PluginOptionsDraft.Mode.table
|
||||
@State private var editingValue: EditingValue?
|
||||
@FocusState private var focusedRowID: UUID?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Picker("查看模式", selection: modeBinding) {
|
||||
Text("参数列表").tag(PluginOptionsDraft.Mode.table)
|
||||
Text("原始文本").tag(PluginOptionsDraft.Mode.rawText)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
switch draft.mode {
|
||||
case .table:
|
||||
tableView
|
||||
case .rawText:
|
||||
rawTextView
|
||||
}
|
||||
fieldErrorLabel
|
||||
}
|
||||
.onChange(of: draft.mode) { _, mode in
|
||||
displayedMode = mode
|
||||
}
|
||||
.onChange(of: fieldError) { _, error in
|
||||
// 首错定位:未完成行时聚焦第一行的参数名。
|
||||
if case .unfinishedPluginOptionRow = error,
|
||||
let first = draft.rows.first(where: \.isUnfinished)
|
||||
{
|
||||
focusedRowID = first.id
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingValue) { value in
|
||||
PluginOptionValueEditorSheet(initialText: value.text) { newValue in
|
||||
draft.updateValue(newValue, of: value.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var modeBinding: Binding<PluginOptionsDraft.Mode> {
|
||||
Binding(
|
||||
get: { displayedMode },
|
||||
set: { requested in
|
||||
let succeeded =
|
||||
requested == .rawText ? draft.switchToRawText() : draft.switchToTable()
|
||||
displayedMode = succeeded ? requested : draft.mode
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - 参数列表
|
||||
|
||||
private var tableView: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(draft.rows) { row in
|
||||
rowView(row)
|
||||
}
|
||||
if isEditable {
|
||||
Button {
|
||||
focusedRowID = draft.quickAddRowID
|
||||
} label: {
|
||||
Label("添加参数", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func rowView(_ row: PluginOptionsDraft.Row) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 8) {
|
||||
keyField(row)
|
||||
valueField(row)
|
||||
kindPicker(row)
|
||||
if isEditable {
|
||||
rowActions(row)
|
||||
}
|
||||
}
|
||||
if row.isUnfinished {
|
||||
Text("缺少参数名")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func keyField(_ row: PluginOptionsDraft.Row) -> some View {
|
||||
if isEditable {
|
||||
TextField(
|
||||
"参数名",
|
||||
text: Binding(
|
||||
get: { row.keyText },
|
||||
set: { draft.updateKey($0, of: row.id) })
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($focusedRowID, equals: row.id)
|
||||
} else {
|
||||
Text(row.keyText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func valueField(_ row: PluginOptionsDraft.Row) -> some View {
|
||||
if isEditable {
|
||||
TextField(
|
||||
"值",
|
||||
text: Binding(
|
||||
get: { row.valueText },
|
||||
set: { draft.updateValue($0, of: row.id) })
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(row.kind == .flag)
|
||||
} else {
|
||||
// 值单元格单行展示、尾部省略;完整内容保留在草稿与无障碍读取中。
|
||||
Text(row.valueText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityLabel(row.kind == .flag ? "无值开关" : "值")
|
||||
}
|
||||
}
|
||||
|
||||
private func kindPicker(_ row: PluginOptionsDraft.Row) -> some View {
|
||||
Picker(
|
||||
"类型",
|
||||
selection: Binding(
|
||||
get: { row.kind },
|
||||
set: { draft.setKind($0, of: row.id) })
|
||||
) {
|
||||
Text("值").tag(PluginOptionsDraft.Kind.keyValue)
|
||||
Text("无值开关").tag(PluginOptionsDraft.Kind.flag)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 96)
|
||||
.disabled(!isEditable)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func rowActions(_ row: PluginOptionsDraft.Row) -> some View {
|
||||
Button {
|
||||
editingValue = EditingValue(id: row.id, text: row.valueText)
|
||||
} label: {
|
||||
Label("编辑值…", systemImage: "square.and.pencil")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(row.kind == .flag)
|
||||
|
||||
Button {
|
||||
draft.deleteRow(row.id)
|
||||
} label: {
|
||||
Label("删除参数行", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
|
||||
// MARK: - 原始文本
|
||||
|
||||
private var rawTextView: some View {
|
||||
Group {
|
||||
if isEditable {
|
||||
TextEditor(text: $draft.rawText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 76, maxHeight: 152)
|
||||
.autocorrectionDisabled()
|
||||
.focused(fieldFocus, equals: .pluginOptions)
|
||||
} else {
|
||||
ScrollView {
|
||||
Text(draft.rawText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(minHeight: 76, maxHeight: 152)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 字段错误
|
||||
|
||||
@ViewBuilder
|
||||
private var fieldErrorLabel: some View {
|
||||
switch fieldError {
|
||||
case .tooManyBytes(let limit):
|
||||
Text("插件参数最多 \(limit) 个字节")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
case .unfinishedPluginOptionRow:
|
||||
Text("存在未完成的参数行:请补全参数名、清空该行或删除它")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
case .missingName, .tooManyCharacters, .invalidPort, nil:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 长值独立编辑(issue #81):等宽多行、初始约四行最多约八行后内部纵向
|
||||
/// 滚动;只有「确认」写回父表单草稿,取消保留之前的值。
|
||||
private struct PluginOptionValueEditorSheet: View {
|
||||
let initialText: String
|
||||
let onConfirm: (String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var text: String
|
||||
|
||||
init(initialText: String, onConfirm: @escaping (String) -> Void) {
|
||||
self.initialText = initialText
|
||||
self.onConfirm = onConfirm
|
||||
_text = State(initialValue: initialText)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("值")
|
||||
.font(.headline)
|
||||
TextEditor(text: $text)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 76, maxHeight: 152)
|
||||
.autocorrectionDisabled()
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("取消", role: .cancel) { dismiss() }
|
||||
Button("确认") {
|
||||
onConfirm(text)
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(minWidth: 380, minHeight: 220)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,10 +135,11 @@ struct ServerFormFieldLimitsTests {
|
||||
@Test
|
||||
func pluginOptionsLimitCountsUTF8BytesOfTheWholeString() {
|
||||
let fields = Self.loadedFields()
|
||||
fields.pluginOptionsText = String(
|
||||
fields.pluginOptions.switchToRawText()
|
||||
fields.pluginOptions.rawText = String(
|
||||
repeating: "a", count: ServerFormFields.pluginOptionsUTF8Limit)
|
||||
#expect(fields.validateForSubmit(), "恰好 65,536 字节达标")
|
||||
fields.pluginOptionsText = String(
|
||||
fields.pluginOptions.rawText = String(
|
||||
repeating: "a", count: ServerFormFields.pluginOptionsUTF8Limit + 1)
|
||||
#expect(!fields.validateForSubmit())
|
||||
#expect(
|
||||
@@ -146,7 +147,7 @@ struct ServerFormFieldLimitsTests {
|
||||
== .tooManyBytes(limit: ServerFormFields.pluginOptionsUTF8Limit))
|
||||
|
||||
// 多字节字符按字节计:21846 个「港」= 65,538 字节越界。
|
||||
fields.pluginOptionsText = String(repeating: "港", count: 21_846)
|
||||
fields.pluginOptions.rawText = String(repeating: "港", count: 21_846)
|
||||
#expect(!fields.validateForSubmit())
|
||||
}
|
||||
|
||||
@@ -154,7 +155,8 @@ struct ServerFormFieldLimitsTests {
|
||||
func hiddenOptionsDraftDoesNotBlockSaveWhenPluginIsNone() {
|
||||
let fields = Self.loadedFields()
|
||||
fields.pluginChoice = .none
|
||||
fields.pluginOptionsText = Self.overLongOptions
|
||||
fields.pluginOptions.switchToRawText()
|
||||
fields.pluginOptions.rawText = Self.overLongOptions
|
||||
#expect(fields.validateForSubmit(), "插件「无」时隐藏参数草稿不阻塞保存")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import Testing
|
||||
|
||||
@testable import ShadowsocksX_NG2
|
||||
|
||||
/// 插件参数无损编辑会话(issue #81):全部经表单草稿模块公开面观察
|
||||
/// (装载、行编辑、模式切换、拼装串、校验与变更检测),不为内部扫描器
|
||||
/// 单独建立测试边界。
|
||||
@MainActor
|
||||
struct ServerFormPluginOptionsTests {
|
||||
private static let certRaw = String(repeating: "A", count: 5_000)
|
||||
|
||||
private static func editForm(pluginOptions: String) -> ServerEditForm {
|
||||
ServerEditForm(
|
||||
address: "203.0.113.7",
|
||||
port: 8388,
|
||||
encryptionMethod: "aes-256-gcm",
|
||||
password: "password",
|
||||
remark: "服务器",
|
||||
plugin: PluginSectionState(
|
||||
selection: .managed(program: "v2ray-plugin"),
|
||||
managed: ManagedPluginCatalog.plugins,
|
||||
provided: true,
|
||||
optionsPresent: !pluginOptions.isEmpty,
|
||||
options: pluginOptions),
|
||||
isEditable: true)
|
||||
}
|
||||
|
||||
private static func loadedFields(_ options: String) -> ServerFormFields {
|
||||
let fields = ServerFormFields()
|
||||
fields.load(from: editForm(pluginOptions: options))
|
||||
return fields
|
||||
}
|
||||
|
||||
private static func formalRows(_ fields: ServerFormFields) -> [PluginOptionsDraft.Row] {
|
||||
fields.pluginOptions.rows.filter { !$0.isBlank }
|
||||
}
|
||||
|
||||
// MARK: - 无编辑往返逐字保留
|
||||
|
||||
@Test(
|
||||
arguments: [
|
||||
"", "tls", "key=",
|
||||
"mode=websocket;host=example.com;path=/ws",
|
||||
"k=1;k=2",
|
||||
"path=/a\\;b",
|
||||
"k=a\\=b",
|
||||
"k=a\\\\b",
|
||||
"a=1;",
|
||||
" host = x ",
|
||||
"未知参数=yes",
|
||||
"港=值",
|
||||
"certRaw=" + String(repeating: "A", count: 5_000),
|
||||
])
|
||||
func noEditSessionsReturnTheOriginalStringVerbatim(_ original: String) {
|
||||
let fields = Self.loadedFields(original)
|
||||
#expect(fields.pluginOptions.mode == .table, "可解析内容默认进入参数列表")
|
||||
#expect(fields.pluginOptions.composedString == original)
|
||||
#expect(!fields.hasChanges, "仅查看不改写参数")
|
||||
#expect(fields.validateForSubmit())
|
||||
#expect(fields.draft?.pluginOptions == original)
|
||||
}
|
||||
|
||||
@Test
|
||||
func modeSwitchingAloneReturnsTheOriginalAndDoesNotMarkChanges() {
|
||||
let fields = Self.loadedFields("a=1;")
|
||||
#expect(fields.pluginOptions.switchToRawText())
|
||||
#expect(fields.pluginOptions.mode == .rawText)
|
||||
#expect(fields.pluginOptions.rawText == "a=1;")
|
||||
#expect(!fields.hasChanges, "切换模式不触发表单变更")
|
||||
#expect(fields.pluginOptions.switchToTable())
|
||||
#expect(fields.pluginOptions.mode == .table)
|
||||
#expect(fields.pluginOptions.composedString == "a=1;")
|
||||
#expect(!fields.hasChanges)
|
||||
}
|
||||
|
||||
@Test(arguments: ["a=1;;b=2", "=x", "a=1\\", ";a=1"])
|
||||
func unparseableOptionsDefaultToRawModeAndStillSubmit(_ original: String) {
|
||||
let fields = Self.loadedFields(original)
|
||||
#expect(fields.pluginOptions.mode == .rawText, "不可可靠解析时默认原始文本")
|
||||
#expect(fields.pluginOptions.composedString == original)
|
||||
#expect(!fields.hasChanges)
|
||||
#expect(fields.validateForSubmit(), "解析失败不是提交的新增阻塞原因")
|
||||
#expect(fields.draft?.pluginOptions == original)
|
||||
}
|
||||
|
||||
// MARK: - 行编辑的局部保留
|
||||
|
||||
@Test
|
||||
func editingOneRowEncodesOnlyThatRow() throws {
|
||||
let fields = Self.loadedFields("tls;host=a;path=/p")
|
||||
let rows = Self.formalRows(fields)
|
||||
let host = try #require(rows.first { $0.keyText == "host" })
|
||||
|
||||
// 只改值:该行重新转义编码,其他行原文照抄。
|
||||
fields.pluginOptions.updateValue("b;c", of: host.id)
|
||||
#expect(fields.pluginOptions.composedString == "tls;host=b\\;c;path=/p")
|
||||
|
||||
// 只改参数名。
|
||||
fields.pluginOptions.updateKey("topic", of: host.id)
|
||||
#expect(fields.pluginOptions.composedString == "tls;topic=b\\;c;path=/p")
|
||||
|
||||
// 编辑后改回原值:该行回到原文写法。
|
||||
fields.pluginOptions.updateKey("host", of: host.id)
|
||||
fields.pluginOptions.updateValue("a", of: host.id)
|
||||
#expect(fields.pluginOptions.composedString == "tls;host=a;path=/p")
|
||||
}
|
||||
|
||||
@Test
|
||||
func addingARowAppendsWithMinimalSeparatorAdjustment() {
|
||||
let fields = Self.loadedFields("a=1")
|
||||
// 在空行开始输入后行身份保持(焦点稳定),新的空行补到末尾。
|
||||
let added = fields.pluginOptions.quickAddRowID
|
||||
fields.pluginOptions.updateKey("b", of: added)
|
||||
fields.pluginOptions.updateValue("2", of: added)
|
||||
#expect(fields.pluginOptions.rows.count == 3)
|
||||
#expect(fields.pluginOptions.composedString == "a=1;b=2")
|
||||
}
|
||||
|
||||
@Test
|
||||
func trailingSeparatorSurvivesContentEditsAndDropsOnlyOnStructuralChange() {
|
||||
let fields = Self.loadedFields("a=1;")
|
||||
let row = Self.formalRows(fields)[0]
|
||||
fields.pluginOptions.updateValue("2", of: row.id)
|
||||
#expect(fields.pluginOptions.composedString == "a=2;", "内容修改保留既有末尾分号")
|
||||
fields.pluginOptions.deleteRow(row.id)
|
||||
#expect(fields.pluginOptions.composedString == "", "删除行后不再保留悬空分隔符")
|
||||
}
|
||||
|
||||
@Test
|
||||
func deletingARowKeepsOtherRowsVerbatim() throws {
|
||||
let fields = Self.loadedFields("tls;host=a;path=/p")
|
||||
let host = try #require(Self.formalRows(fields).first { $0.keyText == "host" })
|
||||
fields.pluginOptions.deleteRow(host.id)
|
||||
#expect(fields.pluginOptions.composedString == "tls;path=/p")
|
||||
}
|
||||
|
||||
// MARK: - 无值开关与空字符串值
|
||||
|
||||
@Test
|
||||
func flagAndEmptyValueAreDistinctAndReversible() {
|
||||
let fields = Self.loadedFields("tls")
|
||||
let row = Self.formalRows(fields)[0]
|
||||
#expect(row.kind == .flag)
|
||||
|
||||
// 无值开关 → 键值:没有会话值草稿则序列化为 key=,不得自动相互推断。
|
||||
fields.pluginOptions.setKind(.keyValue, of: row.id)
|
||||
#expect(fields.pluginOptions.composedString == "tls=")
|
||||
fields.pluginOptions.setKind(.flag, of: row.id)
|
||||
#expect(fields.pluginOptions.composedString == "tls")
|
||||
|
||||
// 键值 → 开关 → 键值:恢复本会话保留的值草稿。
|
||||
let other = Self.loadedFields("host=a")
|
||||
let hostRow = Self.formalRows(other)[0]
|
||||
other.pluginOptions.setKind(.flag, of: hostRow.id)
|
||||
#expect(other.pluginOptions.composedString == "host")
|
||||
other.pluginOptions.setKind(.keyValue, of: hostRow.id)
|
||||
#expect(other.pluginOptions.composedString == "host=a")
|
||||
}
|
||||
|
||||
// MARK: - 底部空行与未完成草稿
|
||||
|
||||
@Test
|
||||
func blankQuickAddRowIsPromotedOnInputAndNeverSerialized() {
|
||||
let fields = Self.loadedFields("a=1")
|
||||
#expect(fields.pluginOptions.rows.count == 2, "一条正式行 + 底部空行")
|
||||
#expect(fields.pluginOptions.composedString == "a=1")
|
||||
|
||||
// 只输入参数名:提升为正式行并补上新的空行。
|
||||
fields.pluginOptions.updateKey("b", of: fields.pluginOptions.quickAddRowID)
|
||||
#expect(fields.pluginOptions.rows.count == 3)
|
||||
#expect(fields.pluginOptions.composedString == "a=1;b=")
|
||||
|
||||
// 清回完全空白:回到占位状态,不进入参数串。
|
||||
fields.pluginOptions.updateKey("", of: fields.pluginOptions.rows[1].id)
|
||||
#expect(fields.pluginOptions.composedString == "a=1")
|
||||
}
|
||||
|
||||
@Test
|
||||
func unfinishedRowsBlockAssemblySaveAndModeSwitch() throws {
|
||||
let fields = Self.loadedFields("a=1")
|
||||
fields.pluginOptions.updateValue("x", of: fields.pluginOptions.quickAddRowID)
|
||||
#expect(fields.pluginOptions.hasUnfinishedRows)
|
||||
#expect(fields.pluginOptions.composedString == nil)
|
||||
#expect(fields.draft == nil)
|
||||
#expect(fields.hasChanges, "未完成输入仍是变更,保存入口不得死锁")
|
||||
#expect(!fields.validateForSubmit())
|
||||
#expect(fields.fieldErrors[.pluginOptions] == .unfinishedPluginOptionRow)
|
||||
#expect(!fields.pluginOptions.switchToRawText(), "未完成行阻止切换到原始文本")
|
||||
#expect(fields.pluginOptions.mode == .table)
|
||||
|
||||
// 重新编辑该行即清除字段错误。
|
||||
let unfinished = try #require(fields.pluginOptions.rows.first { $0.isUnfinished })
|
||||
fields.pluginOptions.updateKey("b", of: unfinished.id)
|
||||
#expect(fields.fieldErrors[.pluginOptions] == nil)
|
||||
#expect(fields.pluginOptions.composedString == "a=1;b=x")
|
||||
#expect(fields.pluginOptions.switchToRawText())
|
||||
}
|
||||
|
||||
@Test
|
||||
func clearedKeyWithKeptValueIsUnfinishedAndNotAutoDeleted() {
|
||||
let fields = Self.loadedFields("a=1")
|
||||
let row = Self.formalRows(fields)[0]
|
||||
fields.pluginOptions.updateKey("", of: row.id)
|
||||
#expect(fields.pluginOptions.rows.contains { $0.id == row.id }, "清空参数名不得自动删除行")
|
||||
#expect(fields.pluginOptions.hasUnfinishedRows)
|
||||
fields.pluginOptions.deleteRow(row.id)
|
||||
#expect(fields.pluginOptions.composedString == "")
|
||||
}
|
||||
|
||||
// MARK: - 原始文本编辑
|
||||
|
||||
@Test
|
||||
func rawTextEditsReparseOnSwitchBackOrStayRawOnFailure() {
|
||||
let fields = Self.loadedFields("a=1")
|
||||
#expect(fields.pluginOptions.switchToRawText())
|
||||
fields.pluginOptions.rawText = "x=1;y"
|
||||
#expect(fields.pluginOptions.switchToTable())
|
||||
#expect(fields.pluginOptions.composedString == "x=1;y")
|
||||
|
||||
// 解析不可靠:留在原始模式并保留原文,不丢弃草稿。
|
||||
fields.pluginOptions.switchToRawText()
|
||||
fields.pluginOptions.rawText = "bad;;"
|
||||
#expect(!fields.pluginOptions.switchToTable())
|
||||
#expect(fields.pluginOptions.mode == .rawText)
|
||||
#expect(fields.pluginOptions.rawText == "bad;;")
|
||||
#expect(fields.pluginOptions.composedString == "bad;;")
|
||||
}
|
||||
|
||||
// MARK: - 装载/重置与提交边界
|
||||
|
||||
@Test
|
||||
func reloadRestoresRowsModeAndRawStateTogether() {
|
||||
let fields = Self.loadedFields("a=1")
|
||||
fields.pluginOptions.updateKey("b", of: fields.pluginOptions.quickAddRowID)
|
||||
fields.pluginOptions.switchToRawText()
|
||||
#expect(fields.hasChanges)
|
||||
|
||||
fields.load(from: Self.editForm(pluginOptions: "a=1"))
|
||||
#expect(fields.pluginOptions.mode == .table)
|
||||
#expect(fields.pluginOptions.composedString == "a=1")
|
||||
#expect(!fields.hasChanges)
|
||||
#expect(fields.fieldErrors.isEmpty)
|
||||
|
||||
fields.load(from: Self.editForm(pluginOptions: "bad;;"))
|
||||
#expect(fields.pluginOptions.mode == .rawText)
|
||||
#expect(fields.pluginOptions.composedString == "bad;;")
|
||||
}
|
||||
|
||||
@Test
|
||||
func composedStringFeedsTheByteLimitAtTheFormBoundary() {
|
||||
let fields = Self.loadedFields("a=1")
|
||||
#expect(fields.pluginOptions.switchToRawText())
|
||||
// 14 × 5,000 字符 + 前缀 > 65,536 字节上限。
|
||||
fields.pluginOptions.rawText = "certRaw=" + String(repeating: Self.certRaw, count: 14)
|
||||
#expect(!fields.validateForSubmit())
|
||||
#expect(
|
||||
fields.fieldErrors[.pluginOptions]
|
||||
== .tooManyBytes(limit: ServerFormFields.pluginOptionsUTF8Limit))
|
||||
#expect(fields.hasChanges)
|
||||
}
|
||||
|
||||
@Test
|
||||
func hiddenPluginSelectionDoesNotBlockOnOptionsDraft() {
|
||||
let fields = Self.loadedFields("a=1")
|
||||
fields.pluginChoice = .none
|
||||
fields.pluginOptions.updateValue("x", of: fields.pluginOptions.quickAddRowID)
|
||||
#expect(fields.validateForSubmit(), "插件「无」时隐藏参数草稿不阻塞保存")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user