#!/usr/bin/env bash
|
set -euo pipefail
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
python3 - "$ROOT_DIR" <<'PY'
|
import json
|
import sys
|
from pathlib import Path
|
|
root = Path(sys.argv[1])
|
app_dir = root / "Apptag"
|
localization_dir = app_dir / "Localization"
|
|
|
def fail(message: str) -> None:
|
raise SystemExit(f"FAIL: {message}")
|
|
|
def read(name: str) -> str:
|
return (app_dir / name).read_text(encoding="utf-8")
|
|
|
def require(source: str, needle: str, message: str) -> None:
|
if needle not in source:
|
fail(message)
|
|
|
def function_body(source: str, signature: str) -> str:
|
start = source.find(signature)
|
if start == -1:
|
fail(f"missing function signature: {signature}")
|
brace = source.find("{", start)
|
if brace == -1:
|
fail(f"missing function body for: {signature}")
|
depth = 0
|
for index in range(brace, len(source)):
|
char = source[index]
|
if char == "{":
|
depth += 1
|
elif char == "}":
|
depth -= 1
|
if depth == 0:
|
return source[brace + 1:index]
|
fail(f"unterminated function body for: {signature}")
|
|
|
pro = read("ProEntitlement.swift")
|
data_layer = read("DataLayer.swift")
|
tag_editor = read("TagEditorView.swift")
|
content = read("ContentView.swift")
|
preferences = read("PreferencesView.swift")
|
access = read("ProAccessViews.swift")
|
|
require(pro, "case customContainerQuota", "ProFeature.customContainerQuota is missing")
|
require(pro, "static let freeCustomContainerLimit = 6", "free custom container limit must be 6")
|
require(pro, "struct ProCustomContainerQuotaStatus", "custom container quota status model is missing")
|
require(pro, "customContainerMaintenanceDecision", "custom container maintenance decision helper is missing")
|
require(pro, "TagDatabase.customMaintainedContainerCount", "quota must count through TagDatabase")
|
require(access, "case .customContainerQuota:", "custom container quota prompt key is missing")
|
|
require(data_layer, "var isCustomMaintained: Bool? = nil", "TagDef must store an optional custom-maintenance marker for old-data compatibility")
|
require(data_layer, "static func customMaintainedContainerCount", "TagDatabase custom container count helper is missing")
|
require(data_layer, "guard let systemCategoryID = definition.systemCategoryID else", "user-created tags must count as custom maintained containers")
|
require(data_layer, "definition.color != systemCategoryID.defaultColorIndex", "system tags with changed base color must count")
|
require(data_layer, "definition.customColor != nil", "system tags with custom color must count")
|
require(data_layer, "markCustomMaintainedIfNeeded", "system tag manual maintenance marker is missing")
|
require(data_layer, "static func createTag(_ name: String, color: Int) -> TagEditorMutationResult", "createTag must return a mutation result")
|
require(data_layer, "customContainerMaintenanceResult(for: [name], in: store)", "createTag must check free quota before saving")
|
require(data_layer, "static func renameTag(from oldName: String, to newName: String) -> TagEditorMutationResult", "renameTag must return a mutation result")
|
require(data_layer, "def.isCustomMaintained = true", "renamed system tags must become custom maintained")
|
require(data_layer, "static func setColor(_ color: Int, for tag: String) -> TagEditorMutationResult", "setColor must return a mutation result")
|
require(data_layer, "willBeCustomMaintained", "setColor must check whether a system tag becomes custom maintained")
|
require(data_layer, "static func appendTags(_ tags: [String], to paths: [String]) -> TagEditorMutationResult", "appendTags must return a mutation result")
|
require(data_layer, "static func removeTags(_ tags: [String], from paths: [String]) -> TagEditorMutationResult", "removeTags must return a mutation result")
|
require(data_layer, "static func moveApp(path: String, from sourceTag: String, to targetTag: String, color: Int, copy: Bool) -> TagEditorMutationResult", "moveApp must return a mutation result")
|
require(data_layer, "TagDatabase.markCustomMaintainedIfNeeded", "membership changes must mark maintained system tags")
|
require(data_layer, "static func reorderTags(_ names: [String]) -> Bool", "tag order persistence must return whether it saved")
|
reorder_tags_body = function_body(data_layer, "static func reorderTags(_ names: [String]) -> Bool")
|
if "ProEntitlementPolicy.isUnlocked(.persistentAppSorting)" in reorder_tags_body:
|
fail("tag list order persistence must be available to free users")
|
|
require(tag_editor, "onCustomContainerQuotaExceeded", "TagEditorView must expose quota-blocked callback")
|
require(tag_editor, "onCustomContainerQuotaStatusChanged", "TagEditorView must expose quota status refresh callback")
|
require(tag_editor, "onCustomContainerQuotaStatusChanged(ProEntitlementPolicy.customContainerQuotaStatus())", "TagEditorView must refresh quota status after successful mutations")
|
require(tag_editor, "onCustomContainerQuotaStatusChanged(status)", "TagEditorView must surface blocked quota status to the header")
|
require(tag_editor, "guard handleMutationResult(result) else { return }", "TagEditorView must not update local state before a save succeeds")
|
require(tag_editor, "blockedCustomContainerQuota", "TagEditorView must surface quota blocks")
|
|
require(content, "persistTagOrder()", "ContentView must persist tag list order")
|
persist_tag_order_body = function_body(content, "private func persistTagOrder()")
|
if "proEntitlement.isUnlocked" in persist_tag_order_body or "pro.tagSorting.previewToast" in persist_tag_order_body:
|
fail("free users must be able to persist tag list order without a Pro preview toast")
|
require(content, "presentCustomContainerQuotaPrompt", "ContentView must show custom container quota prompt")
|
require(content, "customContainerQuotaAccessory", "ContentView tag editor must show quota count")
|
require(content, "@State private var customContainerQuotaStatus = ProEntitlementPolicy.customContainerQuotaStatus()", "ContentView quota count must be backed by SwiftUI state")
|
require(content, "let status = effectiveCustomContainerQuotaStatus", "ContentView quota accessory must read state-backed effective status")
|
require(content, "onCustomContainerQuotaStatusChanged: refreshCustomContainerQuotaStatus", "ContentView must refresh quota count after TagEditorView saves")
|
require(content, "refreshCustomContainerQuotaStatus(in: store)", "ContentView must initialize quota count from the edit-mode store")
|
require(content, "pro.customContainers.manageExisting", "quota prompt secondary action must manage existing tags")
|
require(content, "handleTagMutationResult(result)", "ContentView membership mutations must respect quota blocks")
|
|
require(preferences, "presentSettingsCustomContainerQuotaPrompt", "Settings tag page must show quota prompt")
|
require(preferences, "customContainerQuotaAccessory", "Settings tag page must show quota count")
|
require(preferences, "@State private var settingsCustomContainerQuotaOverviewStatus = ProEntitlementPolicy.customContainerQuotaStatus()", "Settings quota count must be backed by SwiftUI state")
|
require(preferences, "let status = effectiveCustomContainerQuotaStatus", "Settings quota accessory must read state-backed effective status")
|
require(preferences, "onCustomContainerQuotaStatusChanged: refreshSettingsCustomContainerQuotaStatus", "Settings must refresh quota count after TagEditorView saves")
|
require(preferences, "settingsCustomContainerQuotaOverviewStatus = quotaStatus", "Settings scanApps must initialize quota count from the same store")
|
require(preferences, 'title: tr("pro.feature.maintainableTagCount")', "Pro comparison must include maintainable tag count row")
|
if 'title: tr("pro.feature.tagOrderPersistence")' in preferences:
|
fail("Pro comparison must not list tag list order; it is available to free users")
|
require(preferences, 'title: tr("pro.feature.containerAppOrderPersistence")', "Pro comparison must include container app order row")
|
|
first_two = [
|
preferences.find('title: tr("pro.feature.maintainableTagCount")'),
|
preferences.find('title: tr("pro.feature.containerAppOrderPersistence")'),
|
]
|
custom_color_index = preferences.find('title: tr("pro.feature.customTagColors")')
|
if any(index == -1 for index in first_two) or custom_color_index == -1:
|
fail("could not locate Pro comparison feature rows")
|
if not (first_two[0] < first_two[1] < custom_color_index):
|
fail("Pro comparison rows must begin with tag quota and container app order")
|
|
required_l10n = [
|
"pro.feature.customContainerQuota",
|
"pro.feature.customContainerQuota.benefit",
|
"pro.prompt.customContainerQuota",
|
"pro.customContainers.quotaShort",
|
"pro.customContainers.quotaUnlimitedShort",
|
"pro.customContainers.quotaPrompt",
|
"pro.customContainers.manageExisting",
|
"pro.feature.maintainableTagCount",
|
"pro.feature.containerAppOrderPersistence",
|
"settings.proStatus.maxSixTags",
|
"settings.proStatus.unlimitedTags",
|
"settings.proStatus.previewOnly",
|
"settings.proStatus.supported",
|
]
|
|
files = sorted(localization_dir.glob("*.json"))
|
if len(files) != 29:
|
fail(f"expected 29 localization files, found {len(files)}")
|
|
english = json.loads((localization_dir / "en.json").read_text(encoding="utf-8"))
|
for path in files:
|
data = json.loads(path.read_text(encoding="utf-8"))
|
missing = [key for key in required_l10n if not data.get(key)]
|
if missing:
|
fail(f"{path.name} missing keys: {', '.join(missing)}")
|
if not data.get("settings.proStatus.unlimitedTags", "").startswith("✅"):
|
fail(f"{path.name} unlimited tag status must include the leading checkmark")
|
if path.name != "en.json":
|
copied = [key for key in required_l10n if data.get(key) == english.get(key)]
|
if copied:
|
fail(f"{path.name} copies English quota text: {', '.join(copied)}")
|
|
zh_hans = json.loads((localization_dir / "zh-Hans.json").read_text(encoding="utf-8"))
|
if zh_hans.get("pro.customContainers.quotaShort") != "免费用户可以使用6个自定义标签(容器):已使用 %used% / %limit%":
|
fail("zh-Hans custom container quota line must use the approved free-user wording")
|
if zh_hans.get("settings.proStatus.unlimitedTags") != "✅没有限制":
|
fail("zh-Hans unlimited tag status must include the approved leading checkmark")
|
|
print("PASS Pro custom container quota QA: quota gate, free tag-order persistence, Pro table rows, and 29-language keys are wired")
|
PY
|