mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-10 21:01:10 +03:00
docs: Add docs Frigate UI mock view
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/** Build the compact field catalog used by documentation config mocks. */
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, "../..");
|
||||
const schemaPath = path.join(
|
||||
repoRoot,
|
||||
"web/e2e/fixtures/mock-data/config-schema.json",
|
||||
);
|
||||
const localeRoot = path.join(repoRoot, "web/public/locales/en/config");
|
||||
const sectionConfigRoot = path.join(
|
||||
repoRoot,
|
||||
"web/src/components/config-form/section-configs",
|
||||
);
|
||||
const settingsSourcePath = path.join(repoRoot, "web/src/pages/Settings.tsx");
|
||||
const settingsLocalePath = path.join(
|
||||
repoRoot,
|
||||
"web/public/locales/en/views/settings.json",
|
||||
);
|
||||
const outputPath = path.join(
|
||||
repoRoot,
|
||||
"docs/src/components/FrigateConfigMock/manifest.json",
|
||||
);
|
||||
|
||||
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
|
||||
const translations = {
|
||||
global: JSON.parse(
|
||||
fs.readFileSync(path.join(localeRoot, "global.json"), "utf8"),
|
||||
),
|
||||
camera: JSON.parse(
|
||||
fs.readFileSync(path.join(localeRoot, "cameras.json"), "utf8"),
|
||||
),
|
||||
groups: JSON.parse(
|
||||
fs.readFileSync(path.join(localeRoot, "groups.json"), "utf8"),
|
||||
),
|
||||
};
|
||||
const settingsTranslations = JSON.parse(
|
||||
fs.readFileSync(settingsLocalePath, "utf8"),
|
||||
);
|
||||
|
||||
function resolveNode(node) {
|
||||
if (!node || typeof node !== "object") return {};
|
||||
|
||||
if (node.$ref) {
|
||||
const refName = node.$ref.split("/").at(-1);
|
||||
return {
|
||||
...resolveNode(schema.$defs?.[refName]),
|
||||
...node,
|
||||
$ref: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const variants = node.anyOf ?? node.oneOf;
|
||||
if (Array.isArray(variants)) {
|
||||
const concrete = variants.find((variant) => variant.type !== "null");
|
||||
return {
|
||||
...resolveNode(concrete),
|
||||
...node,
|
||||
anyOf: undefined,
|
||||
oneOf: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
function translationAt(level, section, fieldPath) {
|
||||
let current = translations[level]?.[section];
|
||||
for (const segment of fieldPath) {
|
||||
if (!current || typeof current !== "object") return {};
|
||||
current = current[segment];
|
||||
}
|
||||
return current && typeof current === "object" ? current : {};
|
||||
}
|
||||
|
||||
function inferWidget(node) {
|
||||
if (Array.isArray(node.enum)) return "select";
|
||||
if (node.type === "boolean") return "switch";
|
||||
if (
|
||||
["integer", "number"].includes(node.type) &&
|
||||
node.minimum !== undefined &&
|
||||
(node.maximum !== undefined || node.exclusiveMaximum !== undefined)
|
||||
) {
|
||||
return "range";
|
||||
}
|
||||
if (node.type === "integer" || node.type === "number") return "number";
|
||||
if (node.type === "array") return "tags";
|
||||
if (node.type === "object") return "object";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function extractArray(source, key) {
|
||||
const match = source.match(new RegExp(`${key}\\s*:\\s*\\[([\\s\\S]*?)\\]`));
|
||||
return match
|
||||
? [...match[1].matchAll(/["']([^"']+)["']/g)].map((item) => item[1])
|
||||
: [];
|
||||
}
|
||||
|
||||
function extractObjectBlock(source, key) {
|
||||
const match = new RegExp(`\\b${key}\\s*:\\s*\\{`).exec(source);
|
||||
if (!match) return "";
|
||||
const start = source.indexOf("{", match.index);
|
||||
let depth = 0;
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
for (let index = start; index < source.length; index += 1) {
|
||||
const character = source[index];
|
||||
if (quote) {
|
||||
if (escaped) escaped = false;
|
||||
else if (character === "\\") escaped = true;
|
||||
else if (character === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (['"', "'", "`"].includes(character)) {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
if (character === "{") depth += 1;
|
||||
if (character === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(start + 1, index);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractGroups(source) {
|
||||
const fieldGroups = {};
|
||||
const groupsBlock = extractObjectBlock(source, "fieldGroups");
|
||||
for (const match of groupsBlock.matchAll(/(\w+)\s*:\s*\[([\s\S]*?)\]/g)) {
|
||||
fieldGroups[match[1]] = [...match[2].matchAll(/["']([^"']+)["']/g)].map(
|
||||
(item) => item[1],
|
||||
);
|
||||
}
|
||||
return fieldGroups;
|
||||
}
|
||||
|
||||
function loadSectionHints(section, level) {
|
||||
const configPath = path.join(sectionConfigRoot, `${section}.ts`);
|
||||
if (!fs.existsSync(configPath)) return {};
|
||||
const source = fs.readFileSync(configPath, "utf8");
|
||||
const base = extractObjectBlock(source, "base");
|
||||
const override = extractObjectBlock(source, level);
|
||||
const overrideHas = (key) => new RegExp(`\\b${key}\\s*:`).test(override);
|
||||
return {
|
||||
order: overrideHas("fieldOrder")
|
||||
? extractArray(override, "fieldOrder")
|
||||
: extractArray(base, "fieldOrder"),
|
||||
hidden: [
|
||||
...extractArray(base, "hiddenFields"),
|
||||
...extractArray(override, "hiddenFields"),
|
||||
],
|
||||
advanced: overrideHas("advancedFields")
|
||||
? extractArray(override, "advancedFields")
|
||||
: extractArray(base, "advancedFields"),
|
||||
groups: overrideHas("fieldGroups")
|
||||
? extractGroups(override)
|
||||
: extractGroups(base),
|
||||
docs: base.match(/sectionDocs\s*:\s*["']([^"']+)["']/)?.[1] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function groupLabel(level, section, group) {
|
||||
const domain = level === "camera" ? "cameras" : "global";
|
||||
return (
|
||||
translations.groups?.[section]?.[domain]?.[group] ??
|
||||
group.replaceAll("_", " ").replace(/^./, (value) => value.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
function collectFields(level, section, sectionNode, hints) {
|
||||
const fields = {};
|
||||
|
||||
function visit(rawNode, fieldPath = []) {
|
||||
const node = resolveNode(rawNode);
|
||||
const properties = node.properties;
|
||||
if (properties && typeof properties === "object") {
|
||||
for (const [name, child] of Object.entries(properties)) {
|
||||
visit(child, [...fieldPath, name]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (fieldPath.length === 0) return;
|
||||
const key = fieldPath.join(".");
|
||||
const localized = translationAt(level, section, fieldPath);
|
||||
fields[key] = {
|
||||
label: localized.label ?? node.title ?? fieldPath.at(-1),
|
||||
description: localized.description ?? node.description ?? "",
|
||||
widget: inferWidget(node),
|
||||
default: node.default ?? null,
|
||||
enum: node.enum ?? null,
|
||||
minimum: node.minimum ?? node.exclusiveMinimum ?? null,
|
||||
maximum: node.maximum ?? node.exclusiveMaximum ?? null,
|
||||
advanced: hints.advanced?.includes(key) ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
visit(sectionNode);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function buildLevel(level) {
|
||||
const rootProperties =
|
||||
level === "camera"
|
||||
? resolveNode(schema.$defs.CameraConfig).properties
|
||||
: schema.properties;
|
||||
const result = {};
|
||||
|
||||
for (const [section, rawNode] of Object.entries(rootProperties ?? {})) {
|
||||
const node = resolveNode(rawNode);
|
||||
if (!node.properties) continue;
|
||||
|
||||
const hints = loadSectionHints(section, level);
|
||||
const hidden = new Set(hints.hidden ?? []);
|
||||
const fields = collectFields(level, section, node, hints);
|
||||
for (const key of hidden) delete fields[key];
|
||||
|
||||
const localized = translations[level]?.[section] ?? {};
|
||||
result[section] = {
|
||||
label: localized.label ?? rawNode.title ?? node.title ?? section,
|
||||
description: localized.description ?? rawNode.description ?? "",
|
||||
order: hints.order ?? [],
|
||||
groups: Object.entries(hints.groups ?? {}).map(([key, groupFields]) => ({
|
||||
key,
|
||||
label: groupLabel(level, section, key),
|
||||
fields: groupFields,
|
||||
})),
|
||||
docs: hints.docs ?? null,
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseSectionMapping(source, constantName, level) {
|
||||
const match = source.match(
|
||||
new RegExp(`const ${constantName}[^=]*=\\s*\\{([\\s\\S]*?)\\n\\};`),
|
||||
);
|
||||
if (!match) return [];
|
||||
return [...match[1].matchAll(/(\w+)\s*:\s*"([^"]+)"/g)].map(
|
||||
([, section, page]) => ({ section, page, level }),
|
||||
);
|
||||
}
|
||||
|
||||
function buildNavigation() {
|
||||
const source = fs.readFileSync(settingsSourcePath, "utf8");
|
||||
const settingsBlock = source.match(
|
||||
/const settingsGroups\s*=\s*\[([\s\S]*?)\n\];/,
|
||||
)?.[1];
|
||||
if (!settingsBlock) return { groups: [], pages: {} };
|
||||
|
||||
const mappings = [
|
||||
...parseSectionMapping(source, "GLOBAL_SECTION_MAPPING", "global"),
|
||||
...parseSectionMapping(source, "CAMERA_SECTION_MAPPING", "camera"),
|
||||
...parseSectionMapping(source, "ENRICHMENTS_SECTION_MAPPING", "global"),
|
||||
...parseSectionMapping(source, "SYSTEM_SECTION_MAPPING", "global"),
|
||||
];
|
||||
const pages = Object.fromEntries(
|
||||
mappings.map((mapping) => [mapping.page, mapping]),
|
||||
);
|
||||
const groupMatches = [...settingsBlock.matchAll(/\{\s*label:\s*"([^"]+)"/g)];
|
||||
const groups = groupMatches.map((match, index) => {
|
||||
const start = match.index ?? 0;
|
||||
const end = groupMatches[index + 1]?.index ?? settingsBlock.length;
|
||||
const sourceSlice = settingsBlock.slice(start, end);
|
||||
const itemKeys = [...sourceSlice.matchAll(/key:\s*"([^"]+)"/g)].map(
|
||||
(item) => item[1],
|
||||
);
|
||||
return {
|
||||
key: match[1],
|
||||
label: settingsTranslations.menu?.[match[1]] ?? match[1],
|
||||
items: itemKeys.map((key) => ({
|
||||
key,
|
||||
label: settingsTranslations.menu?.[key] ?? key,
|
||||
...(key === "masksAndZones"
|
||||
? { section: key, page: key, level: "camera" }
|
||||
: {}),
|
||||
...(pages[key] ?? {}),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return { groups, pages };
|
||||
}
|
||||
|
||||
function buildDetectorTypes() {
|
||||
const detectorTranslations = translations.global?.detectors ?? {};
|
||||
const reserved = new Set([
|
||||
"label",
|
||||
"description",
|
||||
"type",
|
||||
"model",
|
||||
"model_path",
|
||||
]);
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(detectorTranslations)
|
||||
.filter(
|
||||
([key, value]) =>
|
||||
!reserved.has(key) &&
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
typeof value.label === "string" &&
|
||||
typeof value.description === "string",
|
||||
)
|
||||
.map(([type, value]) => [
|
||||
type,
|
||||
{
|
||||
label: value.label,
|
||||
description: value.description,
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(
|
||||
([key, field]) =>
|
||||
!["label", "description"].includes(key) &&
|
||||
field &&
|
||||
typeof field === "object" &&
|
||||
typeof field.label === "string",
|
||||
)
|
||||
.map(([key, field]) => [
|
||||
key,
|
||||
{
|
||||
label: field.label,
|
||||
description: field.description ?? "",
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
generatedFrom: path.relative(repoRoot, schemaPath).replaceAll("\\", "/"),
|
||||
detectorTypes: buildDetectorTypes(),
|
||||
levels: {
|
||||
global: buildLevel("global"),
|
||||
camera: buildLevel("camera"),
|
||||
},
|
||||
navigation: buildNavigation(),
|
||||
};
|
||||
|
||||
const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
|
||||
if (process.argv.includes("--check")) {
|
||||
const current = fs.existsSync(outputPath)
|
||||
? fs.readFileSync(outputPath, "utf8")
|
||||
: "";
|
||||
if (current !== serialized) {
|
||||
console.error(
|
||||
`${path.relative(repoRoot, outputPath)} is stale. Run npm run build:mock.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Checked ${path.relative(repoRoot, outputPath)}`);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, serialized);
|
||||
console.log(`Generated ${path.relative(repoRoot, outputPath)}`);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ from lib.i18n_loader import load_i18n
|
||||
from lib.nav_map import ALL_CONFIG_SECTIONS
|
||||
from lib.schema_loader import load_schema
|
||||
from lib.section_config_parser import load_section_configs
|
||||
from lib.ui_generator import generate_ui_content, wrap_with_config_tabs
|
||||
from lib.ui_generator import generate_mock_content, generate_ui_content, wrap_with_config_tabs
|
||||
from lib.yaml_extractor import (
|
||||
extract_config_tabs_blocks,
|
||||
extract_yaml_blocks,
|
||||
@@ -60,6 +60,7 @@ def process_file(
|
||||
inject: bool = False,
|
||||
verbose: bool = False,
|
||||
outpath: Path | None = None,
|
||||
mock: bool = False,
|
||||
) -> dict:
|
||||
"""Process a single markdown file for initial injection of bare YAML blocks.
|
||||
|
||||
@@ -114,7 +115,8 @@ def process_file(
|
||||
continue
|
||||
|
||||
# Generate UI content
|
||||
ui_content = generate_ui_content(
|
||||
generator = generate_mock_content if mock else generate_ui_content
|
||||
ui_content = generator(
|
||||
block, schema, i18n, section_configs
|
||||
)
|
||||
|
||||
@@ -188,6 +190,7 @@ def regenerate_file(
|
||||
dry_run: bool = False,
|
||||
verbose: bool = False,
|
||||
outpath: Path | None = None,
|
||||
mock: bool = False,
|
||||
) -> dict:
|
||||
"""Regenerate UI tabs in existing ConfigTabs blocks.
|
||||
|
||||
@@ -233,7 +236,8 @@ def regenerate_file(
|
||||
continue
|
||||
|
||||
# Generate fresh UI content
|
||||
new_ui = generate_ui_content(
|
||||
generator = generate_mock_content if mock else generate_ui_content
|
||||
new_ui = generator(
|
||||
yaml_block, schema, i18n, section_configs
|
||||
)
|
||||
|
||||
@@ -302,6 +306,7 @@ def check_file(
|
||||
i18n: dict,
|
||||
section_configs: dict,
|
||||
verbose: bool = False,
|
||||
mock: bool = False,
|
||||
) -> dict:
|
||||
"""Check for drift between existing UI tabs and what would be generated.
|
||||
|
||||
@@ -333,7 +338,8 @@ def check_file(
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
new_ui = generate_ui_content(
|
||||
generator = generate_mock_content if mock else generate_ui_content
|
||||
new_ui = generator(
|
||||
yaml_block, schema, i18n, section_configs
|
||||
)
|
||||
|
||||
@@ -406,6 +412,10 @@ def _ensure_imports(content: str) -> str:
|
||||
needed_imports.append(
|
||||
'import NavPath from "@site/src/components/NavPath";'
|
||||
)
|
||||
if "<FrigateConfigMock" in content and 'import FrigateConfigMock' not in content:
|
||||
needed_imports.append(
|
||||
'import FrigateConfigMock from "@site/src/components/FrigateConfigMock";'
|
||||
)
|
||||
|
||||
if not needed_imports:
|
||||
return content
|
||||
@@ -472,6 +482,11 @@ def main():
|
||||
action="store_true",
|
||||
help="Show detailed warnings and diagnostics",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mock",
|
||||
action="store_true",
|
||||
help="Generate focused Frigate UI mocks instead of text instructions",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Collect files and determine base directory for relative path computation
|
||||
@@ -525,23 +540,25 @@ def main():
|
||||
print(f"Processing {len(files)} file(s)...\n", file=sys.stderr)
|
||||
|
||||
if args.check:
|
||||
_run_check(files, schema, i18n, section_configs, args.verbose)
|
||||
_run_check(files, schema, i18n, section_configs, args.verbose, args.mock)
|
||||
elif args.regenerate:
|
||||
_run_regenerate(
|
||||
files, schema, i18n, section_configs,
|
||||
args.dry_run, args.verbose, file_outpaths,
|
||||
args.mock,
|
||||
)
|
||||
else:
|
||||
_run_inject(
|
||||
files, schema, i18n, section_configs,
|
||||
args.inject, args.verbose, file_outpaths,
|
||||
args.mock,
|
||||
)
|
||||
|
||||
if outdir is not None:
|
||||
print(f"\nOutput written to: {outdir}", file=sys.stderr)
|
||||
|
||||
|
||||
def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outpaths):
|
||||
def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outpaths, mock):
|
||||
"""Run default mode: preview or inject bare YAML blocks."""
|
||||
total_stats = {
|
||||
"files": 0,
|
||||
@@ -557,6 +574,7 @@ def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outp
|
||||
filepath, schema, i18n, section_configs,
|
||||
inject=inject, verbose=verbose,
|
||||
outpath=file_outpaths.get(filepath),
|
||||
mock=mock,
|
||||
)
|
||||
|
||||
total_stats["files"] += 1
|
||||
@@ -580,7 +598,7 @@ def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outp
|
||||
print("=" * 60, file=sys.stderr)
|
||||
|
||||
|
||||
def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file_outpaths):
|
||||
def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file_outpaths, mock):
|
||||
"""Run regenerate mode: update existing ConfigTabs blocks."""
|
||||
total_stats = {
|
||||
"files": 0,
|
||||
@@ -595,6 +613,7 @@ def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file
|
||||
filepath, schema, i18n, section_configs,
|
||||
dry_run=dry_run, verbose=verbose,
|
||||
outpath=file_outpaths.get(filepath),
|
||||
mock=mock,
|
||||
)
|
||||
|
||||
total_stats["files"] += 1
|
||||
@@ -617,7 +636,7 @@ def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file
|
||||
print("=" * 60, file=sys.stderr)
|
||||
|
||||
|
||||
def _run_check(files, schema, i18n, section_configs, verbose):
|
||||
def _run_check(files, schema, i18n, section_configs, verbose, mock):
|
||||
"""Run check mode: detect drift without modifying files."""
|
||||
total_stats = {
|
||||
"files": 0,
|
||||
@@ -630,6 +649,7 @@ def _run_check(files, schema, i18n, section_configs, verbose):
|
||||
for filepath in files:
|
||||
stats = check_file(
|
||||
filepath, schema, i18n, section_configs, verbose=verbose,
|
||||
mock=mock,
|
||||
)
|
||||
|
||||
total_stats["files"] += 1
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Generate UI tab markdown content from parsed YAML blocks."""
|
||||
"""Generate UI tab content from parsed YAML blocks."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from .i18n_loader import get_field_description, get_field_label, get_value_label
|
||||
@@ -9,6 +10,80 @@ from .section_config_parser import get_hidden_fields
|
||||
from .yaml_extractor import YamlBlock, get_leaf_paths
|
||||
|
||||
|
||||
def generate_mock_content(
|
||||
block: YamlBlock,
|
||||
schema: dict[str, Any],
|
||||
i18n: dict[str, Any],
|
||||
section_configs: dict[str, dict[str, Any]],
|
||||
) -> str | None:
|
||||
"""Generate a focused Frigate config mock for a YAML block."""
|
||||
if block.section_key is None:
|
||||
return None
|
||||
|
||||
if block.is_camera_level:
|
||||
cameras = block.parsed.get("cameras", {})
|
||||
camera_name = block.camera_name or next(iter(cameras), None)
|
||||
if not camera_name or not isinstance(cameras.get(camera_name), dict):
|
||||
return None
|
||||
config = cameras[camera_name]
|
||||
level = "camera"
|
||||
else:
|
||||
config = block.parsed
|
||||
level = detect_level(block.section_key)
|
||||
if level not in ("global", "camera"):
|
||||
level = "global"
|
||||
|
||||
steps: list[dict[str, object]] = []
|
||||
for section, section_data in config.items():
|
||||
if section not in ALL_CONFIG_SECTIONS or not isinstance(
|
||||
section_data, dict
|
||||
):
|
||||
continue
|
||||
|
||||
hidden = get_hidden_fields(section_configs, section, level)
|
||||
values: dict[str, object] = {}
|
||||
for path, value in get_leaf_paths(section_data):
|
||||
path_parts = list(path)
|
||||
if not _is_hidden(path_parts[-1], path_parts, hidden):
|
||||
values[".".join(path_parts)] = value
|
||||
|
||||
if values:
|
||||
steps.append(
|
||||
{
|
||||
"section": section,
|
||||
"level": level,
|
||||
"fields": list(values),
|
||||
"values": values,
|
||||
"focus": next(iter(values)),
|
||||
}
|
||||
)
|
||||
|
||||
if not steps:
|
||||
return None
|
||||
|
||||
if len(steps) == 1:
|
||||
step = steps[0]
|
||||
return "\n".join(
|
||||
[
|
||||
"<FrigateConfigMock",
|
||||
f' section="{step["section"]}"',
|
||||
f' level="{step["level"]}"',
|
||||
f" fields={{{json.dumps(step['fields'])}}}",
|
||||
f" values={{{json.dumps(step['values'])}}}",
|
||||
f' focus="{step["focus"]}"',
|
||||
"/>",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
"<FrigateConfigMock",
|
||||
f" steps={{{json.dumps(steps)}}}",
|
||||
"/>",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _format_value(
|
||||
value: object,
|
||||
field_schema: dict[str, Any] | None,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests for focused Frigate configuration mock generation."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
try:
|
||||
import yaml # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
yaml_stub = types.ModuleType("yaml")
|
||||
yaml_stub.YAMLError = ValueError
|
||||
yaml_stub.safe_load = lambda _value: {}
|
||||
sys.modules["yaml"] = yaml_stub
|
||||
|
||||
from lib.ui_generator import generate_mock_content
|
||||
from lib.yaml_extractor import YamlBlock
|
||||
|
||||
|
||||
def make_block(parsed: dict, section: str, camera: bool = False) -> YamlBlock:
|
||||
"""Create a parsed YAML block for generator tests."""
|
||||
return YamlBlock(
|
||||
raw="",
|
||||
parsed=parsed,
|
||||
line_start=1,
|
||||
line_end=1,
|
||||
highlight=None,
|
||||
has_comments=False,
|
||||
inside_config_tabs=False,
|
||||
section_key=section,
|
||||
is_camera_level=camera,
|
||||
camera_name="front_door" if camera else None,
|
||||
config_keys=list(parsed),
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateMockContent(unittest.TestCase):
|
||||
def test_generates_focused_global_section(self):
|
||||
content = generate_mock_content(
|
||||
make_block({"motion": {"threshold": 30}}, "motion"),
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
)
|
||||
|
||||
self.assertIn('section="motion"', content)
|
||||
self.assertIn('level="global"', content)
|
||||
self.assertIn('fields={["threshold"]}', content)
|
||||
self.assertIn('values={{"threshold": 30}}', content)
|
||||
self.assertIn('focus="threshold"', content)
|
||||
|
||||
def test_unwraps_camera_and_omits_hidden_fields(self):
|
||||
content = generate_mock_content(
|
||||
make_block(
|
||||
{
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"motion": {
|
||||
"threshold": 20,
|
||||
"raw_mask": "ignored",
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"motion",
|
||||
camera=True,
|
||||
),
|
||||
{},
|
||||
{},
|
||||
{"motion": {"hiddenFields": ["raw_mask"]}},
|
||||
)
|
||||
|
||||
self.assertIn('level="camera"', content)
|
||||
self.assertIn('fields={["threshold"]}', content)
|
||||
self.assertNotIn("raw_mask", content)
|
||||
|
||||
def test_generates_steps_for_multiple_sections(self):
|
||||
content = generate_mock_content(
|
||||
make_block(
|
||||
{
|
||||
"record": {"enabled": True},
|
||||
"snapshots": {"enabled": True},
|
||||
},
|
||||
"record",
|
||||
),
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
)
|
||||
|
||||
self.assertIn("steps={", content)
|
||||
self.assertIn('"section": "record"', content)
|
||||
self.assertIn('"section": "snapshots"', content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user