核查类节点检查器的通用控件合成一份:选画布节点、三态开关、核查模型选择只留在 verifyFields.tsx

期 3 两个工作包各写了一份:双盲复算在 DualCheckFields.tsx(选口径卡、启用三态、盲算模型),反方审稿在 verifyFields.tsx(选报告、启用三态、反方模型),说法和细节各不相同、修一处漏一处。现在双盲复算改用 verifyFields 的 NodePick、TriState、ProviderPick、ModelInput,只给自己的文案;它特有的选指标、口径说明、相对误差留在 DualCheckFields.tsx。写进配置的值不变(留空写回 undefined,三态只认真布尔)。

- NodePick 加可选的分组(上游的一组、画布上其余同类的一组,选了不在上游的照样能选、下面提醒连到上游)、下拉项和缺省名的写法;写着的卡被删了、写的不是口径卡(助手改图、导入 JSON 会写出这种配置)时双盲复算也照实列出、说一句,不再只认「不在画布上」
- TriState 可选在「跟随设置」后面写出设置里现在是开是关(双盲复算用,每次打开检查器重取设置);设置的取法 useVerifyDefaults 挪到这里共用,用不上的控件不发请求
- 画布节点、连线的类型收窄成两边都给得出的 CanvasNode、CanvasEdge,节点卡片摘要和检查器共用 upstreamOf、resolvePick
- check-verify 跟着改:提示、三态、按模型名找接入的标记换成共用控件的;字段叫法从 engine/labels.py 读(期 4 从 schema.py 挪过去);补写着的卡不在画布上、不是口径卡两种情形
This commit is contained in:
ThearchyHelios
2026-10-06 08:10:10 +08:00
parent 20c24fcf8c
commit f8adbaa463
4 changed files with 237 additions and 252 deletions
+53 -164
View File
@@ -1,62 +1,41 @@
import { useEffect, useMemo, useState } from 'react'
import { useMemo } from 'react'
import type { ReactNode } from 'react'
import { X } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../api/client'
import { useStudio } from '../store/studio'
import { modelOptions, providerOfModel, useCatalog } from '../store/catalog'
import { useRadioGroup } from '../components/ui'
import { DUAL_CHECK_TEXT } from '../lib/terms'
import type { VerifySettings } from '../types'
import type { FieldRenderProps, NodeSummaryContext } from './nodeDefs'
import {
ModelInput, NodePick, ProviderPick, TriState, resolvePick, upstreamOf, useVerifyDefaults,
type CanvasEdge, type CanvasNode, type NodePickText, type TriStateText, type VerifyModelText,
} from './verifyFields'
/*
* 双盲复算节点的检查器控件(nodeDefs 里 dual_check 各字段的 render)和节点卡片的摘要。
*
* 口径卡、要复算的指标、口径说明都得看画布上别的节点:从 studio store 读,不往检查器的 props 里加字段。
* 每个控件只写自己那个键(FieldRenderProps.onChange);写回 undefined 就是「留空 = 跟随默认」,配置里不留空值。
* 后端认的键和缺省见 types.ts 的 DualCheckConfig
* 选口径卡、盲算模型、启用三态用核查类节点共用的控件(verifyFields.tsx,反方审稿也用它们),这里只给文案;双盲复算
* 特有的选指标、口径说明、相对误差在这里。口径卡、要复算的指标、口径说明都得看画布上别的节点:从 studio store 读,
* 不往检查器的 props 里加字段。每个控件只写自己那个键(FieldRenderProps.onChange);写回 undefined 就是「留空 = 跟随
* 默认」,配置里不留空值。后端认的键和缺省见 types.ts 的 DualCheckConfig
*/
const T = DUAL_CHECK_TEXT
type CanvasNode = NodeSummaryContext['nodes'][number]
type CanvasEdge = NodeSummaryContext['edges'][number]
const isRecord = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v)
const ids = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && !!x) : [])
/** 某个节点的全部上游(沿连线往回走),不含它自己 */
function upstreamOf(id: string, edges: readonly CanvasEdge[]): Set<string> {
const parents = new Map<string, string[]>()
for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source])
const seen = new Set<string>()
const stack = [...(parents.get(id) ?? [])]
while (stack.length) {
const cur = stack.pop()!
if (seen.has(cur) || cur === id) continue
seen.add(cur)
stack.push(...(parents.get(cur) ?? []))
}
return seen
}
/** 画布上的口径卡:本节点上游的在前(复算只能复算它们),其余的另列 */
function cardsOf(id: string, nodes: readonly CanvasNode[], edges: readonly CanvasEdge[]) {
const up = upstreamOf(id, edges)
const cards = nodes.filter((n) => n.data.nodeType === 'metrics')
return { upstream: cards.filter((n) => up.has(n.id)), other: cards.filter((n) => !up.has(n.id)) }
}
/**
* 实际复算哪张卡:写了 card 就是它(不在画布上时 node 为 null);没写取上游的口径卡,上游只有一张时就是它,
* 好几张时说不准,node 为 null、由调用方照实说
* 实际复算哪张卡:写了 card 就是它(不在画布上、不是口径卡时 node 为 null;不在上游也照样是它,指标照列,选卡的地方
* 另外提醒);没写取上游的口径卡(schema.dual_check_card 的认法),上游只有一张时就是它,好几张时说不准,node 为 null、
* 由调用方照实说
*/
function targetOf(config: Record<string, any>, id: string, nodes: readonly CanvasNode[], edges: readonly CanvasEdge[]) {
const { upstream } = cardsOf(id, nodes, edges)
const up = upstreamOf(id, edges)
const upstream = nodes.filter((n) => up.has(n.id) && n.data.nodeType === 'metrics')
const card = typeof config.card === 'string' && config.card ? config.card : ''
if (card) return { node: nodes.find((n) => n.id === card && n.data.nodeType === 'metrics') ?? null, card, upstream }
return { node: upstream.length === 1 ? upstream[0] : null, card: '', upstream }
const pick = resolvePick(id, card, 'metrics', 'upstream', nodes, edges)
const node = pick.kind === 'target' || pick.kind === 'notUpstream' ? pick.node : null
return { node, card, upstream }
}
/** 一张口径卡在摘要里的名字:节点名,没起名的用口径名,再没有用 id */
@@ -92,33 +71,7 @@ export function dualCheckSummary({ id, config, nodes, edges }: NodeSummaryContex
return config.enabled === false ? `${text} · ${S.off}` : text
}
// ---------------------------------------------------------------- 设置里的默认值
/**
* 设置里核查一组的值:相对误差、启用的「跟随设置」要写出现在是多少。每次打开检查器都重取一次——去设置页关掉双盲
* 复算、改了误差再回到画布,这里得是新值(不同于检查器的运行默认值,核查开关是常改的)。先用上次取到的画,免得
* 闪一下;同一次打开里的几个字段共用一个请求;取不到就沿用上次的(没有就不写),不挡编辑
*/
let verifyPending: Promise<VerifySettings | null> | null = null
let verifyValue: VerifySettings | null | undefined
function fetchVerify(): Promise<VerifySettings | null> {
verifyPending ??= api.settings.get().then(
(s) => (verifyValue = isRecord(s?.verify) ? s.verify as VerifySettings : null),
() => verifyValue ?? null,
).finally(() => { verifyPending = null })
return verifyPending
}
function useVerifyDefaults(): VerifySettings | null {
const [value, setValue] = useState<VerifySettings | null>(verifyValue ?? null)
useEffect(() => {
let alive = true
void fetchVerify().then((v) => { if (alive) setValue(v) })
return () => { alive = false }
}, [])
return value
}
/** 字段下面的一行提醒(所选口径卡不在上游之类),提醒色 */
/** 字段下面的一行提醒(写了说明的指标不在复算范围里之类),提醒色 */
function Warn({ children, attr }: { children: ReactNode; attr: string }) {
return (
<div className="mt-1 text-2xs leading-snug" style={{ color: 'var(--st-waiting)' }} {...{ [attr]: '' }}>{children}</div>
@@ -127,33 +80,23 @@ function Warn({ children, attr }: { children: ReactNode; attr: string }) {
// ---------------------------------------------------------------- 各字段
/** 要复算的口径卡:下拉选画布上的口径卡,上游的一组、其余的一组;留空取上游的口径卡 */
function CardField({ id, nodeId, value, invalid, describedBy, onChange }: FieldRenderProps) {
const nodes = useStudio((s) => s.nodes)
const edges = useStudio((s) => s.edges)
const { upstream, other } = useMemo(() => cardsOf(nodeId, nodes, edges), [nodeId, nodes, edges])
const current = typeof value === 'string' ? value : ''
const known = [...upstream, ...other].some((n) => n.id === current)
const option = (n: CanvasNode) => (
<option key={n.id} value={n.id}>{T.cardOption(String(n.data.label || n.id), n.data.config?.caliber)}</option>
)
return (
<>
<select id={id} className="field" value={current} aria-invalid={invalid || undefined} aria-describedby={describedBy}
data-dual-field="card" onChange={(e) => onChange(e.target.value || undefined)}>
<option value="">{T.cardDefault(upstream.length === 1 ? cardName(upstream[0]) : null, upstream.length)}</option>
{upstream.length > 0 && <optgroup label={T.cardUpstream}>{upstream.map(option)}</optgroup>}
{other.length > 0 && <optgroup label={T.cardOther}>{other.map(option)}</optgroup>}
{/* 写着的卡已经不在画布上:照实列出来(不让它看着像「默认」),下面再说一句 */}
{current && !known && <option value={current}>{T.cardGone(current)}</option>}
</select>
{/* 上游有好几张又没选:服务端校验会挡住发起运行,用提醒色写明要选 */}
{!current && upstream.length > 1 && <Warn attr="data-dual-card-warn">{T.cardChoose(upstream.length)}</Warn>}
{current && known && !upstream.some((n) => n.id === current) && <Warn attr="data-dual-card-warn">{T.cardNotUpstream}</Warn>}
{current && !known && <Warn attr="data-dual-card-warn">{T.cardMissing(current)}</Warn>}
</>
)
/**
* 要复算的口径卡(共用的 NodePick):上游的一组、其余的一组(选了不在上游的,提醒把它连到上游);留空取上游的口径卡,
* 上游有好几张又没选时写明要选(服务端校验会挡住发起运行);写着的卡不在画布上、不是口径卡时照实列出来、说一句
*/
const CARD_TEXT: NodePickText = {
auto: (only, count) => T.cardDefault(only, count),
many: (n) => T.cardChoose(n),
missing: (id) => T.cardGone(id),
notUpstream: (label) => label,
wrongType: (label) => T.cardWrongType(label),
missingHint: T.cardMissingHint,
notUpstreamHint: T.cardNotUpstream,
wrongTypeHint: (type) => T.cardWrongTypeHint(type),
groups: { upstream: T.cardUpstream, other: T.cardOther },
}
/** 一张口径卡在下拉里的叫法:节点名,口径名不同时附在后面 */
const cardOption = (n: CanvasNode) => T.cardOption(String(n.data.label || n.id), n.data.config?.caliber)
/**
* 复算的指标:所选口径卡的指标逐个勾。没写(全部勾着)是缺省——复算输入来自查询的全部指标;全部勾回来时写回
@@ -280,52 +223,12 @@ function DefinitionsField({ id, nodeId, value, config, describedBy, onChange }:
}
/**
* 盲算模型 · 接入。照报告节点「结论句裁判」的写法:留空跟随设置;只填了模型时接入按模型名找,留空那一项改叫
* 「按模型名找接入」(verify_model_spec 哪一级写了就整组用哪一级,不跨级拼)
* 盲算模型(共用的 ProviderPick、ModelInput):留空跟随设置;只填了模型时接入按模型名找,留空那一项改叫「按模型名找
* 接入」,并写明实际用哪个接入(verify_model_spec 哪一级写了就整组用哪一级,不跨级拼)。取值链的说明在字段的帮助里
*/
function ProviderField({ id, value, config, invalid, describedBy, onChange }: FieldRenderProps) {
const providers = useCatalog((s) => s.providers)
const provider = typeof value === 'string' ? value : ''
const known = providers.some((p) => p.name === provider)
const byModel = !provider && typeof config.model === 'string' && !!config.model.trim()
return (
<select id={id} className="field" value={provider} aria-invalid={invalid || undefined} aria-describedby={describedBy}
data-dual-field="provider" onChange={(e) => onChange(e.target.value || undefined)}>
<option value="">{byModel ? T.providerFromModel : T.providerFollow}</option>
{provider && !known && providers.length > 0 && <option value={provider}>{T.providerGone(provider)}</option>}
{providers.map((p) => <option key={p.id} value={p.name}>{p.name}{p.enabled ? '' : T.providerOff}</option>)}
</select>
)
}
/** 盲算模型 · 模型:可以手填,下拉给所选接入(没选接入时给全部启用接入)的模型;只填了模型时写明实际用哪个接入 */
function ModelField({ id, value, config, invalid, describedBy, onChange }: FieldRenderProps) {
const providers = useCatalog((s) => s.providers)
const provider = typeof config.provider === 'string' ? config.provider : ''
const pv = provider ? providers.find((p) => p.name === provider) : undefined
const model = typeof value === 'string' ? value : ''
const byModel = !provider && !!model.trim()
const owner = byModel ? providerOfModel(providers, model.trim()) : undefined
const enabled = providers.filter((p) => p.enabled)
const fallback = enabled.find((p) => p.kind !== 'mock') ?? enabled[0]
const hint = byModel && providers.length > 0
? T.byModel(model.trim(), owner && { name: owner.provider.name, enabled: owner.enabled }, fallback?.name) : ''
const models = pv ? (pv.models ?? []).map((m) => m.id) : [...new Set(modelOptions(providers).map((o) => o.value))]
return (
<>
<input id={id} className="field mono text-xs" list={`${id}-models`} value={model} spellCheck={false}
placeholder={provider ? T.modelOfProvider : T.modelFollow} aria-invalid={invalid || undefined}
aria-describedby={describedBy} data-dual-field="model"
onChange={(e) => onChange(e.target.value.trim() ? e.target.value : undefined)} />
<datalist id={`${id}-models`}>{models.map((m) => <option key={m} value={m} />)}</datalist>
{hint && (
<div className="mt-1 text-2xs leading-snug" data-dual-by-model={owner ? (owner.enabled ? 'found' : 'disabled') : 'default'}
style={owner?.enabled ? undefined : { color: 'var(--st-waiting)' }}>
<span className={owner?.enabled ? 'text-faint' : undefined}>{hint}</span>
</div>
)}
</>
)
const MODEL_TEXT: VerifyModelText = {
providerFollow: T.providerFollow, providerFromModel: T.providerFromModel, providerMissing: T.providerGone,
providerDisabled: T.providerOff, modelFollow: T.modelFollow, modelDefault: T.modelOfProvider, byModel: T.byModel,
}
/** 允许的相对误差:留空跟随设置,占位里写出设置里眼下是多少;负数标出来(服务端也不收) */
@@ -344,39 +247,25 @@ function ToleranceField({ id, value, invalid, describedBy, onChange }: FieldRend
)
}
const ENABLED = ['follow', 'on', 'off'] as const
type Enabled = typeof ENABLED[number]
/** 启用:跟随设置 / 开启 / 关闭三态(null 或不写、true、false)。跟随设置时写回 undefined,不在配置里留 null */
function EnabledField({ id, value, describedBy, onChange }: FieldRenderProps) {
const settings = useVerifyDefaults()
const current: Enabled = value === true ? 'on' : value === false ? 'off' : 'follow'
const pick = (v: Enabled) => onChange(v === 'on' ? true : v === 'off' ? false : undefined)
const radio = useRadioGroup(ENABLED, current, pick)
return (
<div id={id} role="radiogroup" aria-label={T.field.enabled} aria-describedby={describedBy}
className="flex flex-wrap gap-0.5 rounded border p-0.5" data-dual-field="enabled">
{ENABLED.map((v) => (
<button key={v} type="button" {...radio(v)} data-value={v}
// 按字数分宽度、不折行:「跟随设置(当前开启)」比「开启」长得多,平分会把它挤成两行;太窄时整段换到下一行
className={clsx('flex-auto whitespace-nowrap rounded px-2 py-1 text-2xs transition-colors',
current === v ? 'font-medium text-fg' : 'text-dim hover:bg-hover')}
style={current === v ? { background: 'var(--accent-soft)', boxShadow: 'inset 0 0 0 1px var(--accent)' } : undefined}
onClick={() => pick(v)}>
{v === 'follow' ? T.enabledFollow(settings?.dual_check?.enabled) : T.enabled[v]}
</button>
))}
</div>
)
/**
* 启用(共用的 TriState):跟随设置 / 开启 / 关闭三态(null 或不写、true、false)。跟随设置时写回 undefined,不在配置里
* 留 null;「跟随设置」后面写出设置里现在是开是关(每次打开检查器都重取)。各态的说明在字段的帮助里
*/
const ENABLED_TEXT: TriStateText = {
follow: T.enabled.follow, on: T.enabled.on, off: T.enabled.off, followNow: (on) => T.enabledFollow(on),
}
/** nodeDefs 里 dual_check 各字段的 render */
export const dualCheckFields = {
card: (p: FieldRenderProps) => <CardField {...p} />,
card: (p: FieldRenderProps) => (
<NodePick p={p} fieldKey="card" nodeType="metrics" rule="upstream" text={CARD_TEXT} optionLabel={cardOption} nameOf={cardName} />
),
metrics: (p: FieldRenderProps) => <MetricsField {...p} />,
definitions: (p: FieldRenderProps) => <DefinitionsField {...p} />,
provider: (p: FieldRenderProps) => <ProviderField {...p} />,
model: (p: FieldRenderProps) => <ModelField {...p} />,
provider: (p: FieldRenderProps) => <ProviderPick p={p} text={MODEL_TEXT} />,
model: (p: FieldRenderProps) => <ModelInput p={p} fieldKey="model" text={MODEL_TEXT} />,
tolerance: (p: FieldRenderProps) => <ToleranceField {...p} />,
enabled: (p: FieldRenderProps) => <EnabledField {...p} />,
enabled: (p: FieldRenderProps) => (
<TriState p={p} label={T.field.enabled} text={ENABLED_TEXT} setting={(v) => v.dual_check?.enabled} />
),
}
+132 -59
View File
@@ -1,19 +1,21 @@
import { useMemo } from 'react'
import type { Edge } from '@xyflow/react'
import { useEffect, useMemo, useState } from 'react'
import clsx from 'clsx'
import { api } from '../api/client'
import { useRadioGroup } from '../components/ui'
import { nodeTypeLabel } from '../lib/terms'
import { modelOptions, providerOfModel, useCatalog } from '../store/catalog'
import { useStudio, type FlowNode } from '../store/studio'
import type { NodeType } from '../types'
import { useStudio } from '../store/studio'
import type { NodeType, VerifySettings } from '../types'
import { fieldOfIssue } from './issues'
import type { FieldDef, FieldRenderProps } from './nodeDefs'
/**
* 核查类节点(反方审稿、双盲复算)检查器里的几样通用控件,nodeDefs 用下面的工厂函数拼进字段表:
* - nodePickField:从画布上挑本节点上游的某一类节点(要审的报告、要复算的口径卡),留空按缺省取上游的那一个;
* - triStateField:跟随设置 / 开启 / 关闭三态(配置里没有这一项、true、false);
* - verifyModelFields:核查模型的接入加模型,写法同结论句裁判的模型选择。
* 核查类节点(反方审稿、双盲复算)检查器里的几样通用控件,两个节点共用这一份(期 3 两个工作包各写过一份,期 4 合并):
* - NodePick / nodePickField:从画布上挑本节点上游的某一类节点(要审的报告、要复算的口径卡),留空按缺省取上游的那一个;
* - TriState / triStateField:跟随设置 / 开启 / 关闭三态(配置里没有这一项、true、false);
* - ProviderPick、ModelInput / verifyModelFields:核查模型的接入加模型,写法同结论句裁判的模型选择;
* - useVerifyDefaults:设置里「核查」一组的当前值(「跟随设置」后面写出现在是多少)。
* 双盲复算特有的(选指标、口径说明、相对误差)留在 DualCheckFields.tsx。
*
* 文案一律由调用方传进来(各节点自己那一块的 *_TEXT),控件里不写死任何一句话:两个节点说法不同(反方模型 / 盲算模型),
* 控件是同一个。读画布节点用 studio store,不往 FieldRenderProps 里加字段。
@@ -22,6 +24,10 @@ import type { FieldDef, FieldRenderProps } from './nodeDefs'
* 再画一句就是同一件事说两遍(同结论句裁判「校验也会报,报了就不重复说」)。校验没回来、校验失败时照画。
*/
/** 画布上的节点、连线只用得到这几项:检查器给的是 FlowNode / Edge,节点卡片的摘要给的是 NodeSummaryContext 里的 */
export interface CanvasNode { id: string; data: { nodeType: NodeType; label?: string; config?: Record<string, any> } }
export interface CanvasEdge { source: string; target: string }
/**
* 画布校验在这个节点的这一项上报了问题没有。认法和 Inspector 把问题挂到字段下面时一样(fieldOfIssue),
* 两边说的「这一项有问题」不会对不上
@@ -34,13 +40,13 @@ function useReported(nodeId: string, key: string): boolean {
}
/** 节点在下拉里的叫法:名字和 id 不同时把 id 带上(同 NodeRefsInput) */
function nodeLabel(n: FlowNode): string {
function nodeLabel(n: CanvasNode): string {
const label = String(n.data?.label || n.id)
return label === n.id ? label : `${label}(${n.id})`
}
/** 本节点的全部上游(沿连线往回找)。只认上游:下游的节点运行到这里时还没有产出 */
function upstreamOf(nodeId: string, edges: Edge[]): Set<string> {
/** 某个节点的全部上游(沿连线往回找),不含它自己。只认上游:下游的节点运行到这里时还没有产出 */
export function upstreamOf(nodeId: string, edges: readonly CanvasEdge[]): Set<string> {
const parents = new Map<string, string[]>()
for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source])
const seen = new Set<string>()
@@ -66,17 +72,17 @@ export type PickRule = 'upstream' | 'direct'
* 配置里那一项按服务端的认法落到哪个节点上:target 是定下来的那一个;其余几种是定不下来的原因——写了的节点不存在、
* 不是这一类、不在上游(先后同服务端),留空时上游一个都没有、不止一个
*/
export type PickState =
| { kind: 'target'; node: FlowNode }
export type PickState<N extends CanvasNode = CanvasNode> =
| { kind: 'target'; node: N }
| { kind: 'missing' }
| { kind: 'wrongType'; node: FlowNode }
| { kind: 'notUpstream'; node: FlowNode }
| { kind: 'wrongType'; node: N }
| { kind: 'notUpstream'; node: N }
| { kind: 'none' }
| { kind: 'many'; count: number }
export function resolvePick(
nodeId: string, chosen: unknown, type: NodeType, rule: PickRule, nodes: FlowNode[], edges: Edge[],
): PickState {
export function resolvePick<N extends CanvasNode>(
nodeId: string, chosen: unknown, type: NodeType, rule: PickRule, nodes: readonly N[], edges: readonly CanvasEdge[],
): PickState<N> {
const up = upstreamOf(nodeId, edges)
if (typeof chosen === 'string' && chosen) {
const node = nodes.find((n) => n.id === chosen)
@@ -95,20 +101,48 @@ export function resolvePick(
}
/** 这个节点实际要核查的是哪一个;定不下来(服务端这时报错、不核查)时返回 undefined */
export function targetNode(
nodeId: string, chosen: unknown, type: NodeType, rule: PickRule, nodes: FlowNode[], edges: Edge[],
): FlowNode | undefined {
export function targetNode<N extends CanvasNode>(
nodeId: string, chosen: unknown, type: NodeType, rule: PickRule, nodes: readonly N[], edges: readonly CanvasEdge[],
): N | undefined {
const pick = resolvePick(nodeId, chosen, type, rule, nodes, edges)
return pick.kind === 'target' ? pick.node : undefined
}
// ---------------------------------------------------------------- 设置里的核查一组
/**
* 设置里核查一组的值:「跟随设置」后面写出现在是开是关、相对误差的占位写出现在是多少。每次打开检查器都重取一次——去
* 设置页关掉双盲复算、改了误差再回到画布,这里得是新值(不同于检查器的运行默认值,核查开关是常改的)。先用上次取到的
* 画,免得闪一下;同一次打开里的几个字段共用一个请求;取不到就沿用上次的(没有就不写),不挡编辑
*/
let verifyPending: Promise<VerifySettings | null> | null = null
let verifyValue: VerifySettings | null | undefined
function fetchVerify(): Promise<VerifySettings | null> {
verifyPending ??= api.settings.get().then(
(s) => (verifyValue = s?.verify && typeof s.verify === 'object' ? s.verify as VerifySettings : null),
() => verifyValue ?? null,
).finally(() => { verifyPending = null })
return verifyPending
}
export function useVerifyDefaults(active = true): VerifySettings | null {
const [value, setValue] = useState<VerifySettings | null>(verifyValue ?? null)
useEffect(() => {
// 用不上设置的控件(反方审稿的启用不写当前值)不取,打开检查器不多发一个请求
if (!active) return
let alive = true
void fetchVerify().then((v) => { if (alive) setValue(v) })
return () => { alive = false }
}, [active])
return value
}
// ---------------------------------------------------------------- 选画布上的节点
export interface NodePickText {
/** 留空那一项:按缺省能认出是哪一个时给出它的名字 */
auto: (only: string | null) => string
/** 上游一个都没有 */
none: string
/** 留空那一项:按缺省能认出是哪一个时给出它的名字;count 是上游这一类节点有几个 */
auto: (only: string | null, count: number) => string
/** 上游一个都没有时下面那句提示;不给就不提示(留空那一项已经说了) */
none?: string
/** 上游不止一个、又没有选 */
many: (n: number) => string
/** 选中的节点已不在画布上(下拉里那一项的叫法) */
@@ -121,45 +155,69 @@ export interface NodePickText {
notUpstreamHint: string
/** type 是选中节点的类型名(「输入」) */
wrongTypeHint: (type: string) => string
/**
* 下拉里把上游的和画布上其余同类节点分两组列时,两组的标题(双盲复算的口径卡:选了不在上游的,提示把它连到上游)。
* 不给时只列上游的
*/
groups?: { upstream: string; other: string }
}
function NodePick({ p, fieldKey, nodeType, rule, text }: {
p: FieldRenderProps; fieldKey: string; nodeType: NodeType; rule: PickRule; text: NodePickText
}) {
export interface NodePickOptions {
fieldKey: string
nodeType: NodeType
rule: PickRule
text: NodePickText
/** 下拉里一个节点的叫法,缺省是「名字(id)」 */
optionLabel?: (n: CanvasNode) => string
/** 留空那一项里,按缺省认出的那一个的名字,缺省是节点名(没起名的用 id) */
nameOf?: (n: CanvasNode) => string
}
const nodeName = (n: CanvasNode): string => String(n.data?.label || n.id)
export function NodePick({ p, fieldKey, nodeType, rule, text, optionLabel = nodeLabel, nameOf = nodeName }: NodePickOptions & { p: FieldRenderProps }) {
const nodes = useStudio((s) => s.nodes)
const edges = useStudio((s) => s.edges)
const reported = useReported(p.nodeId, fieldKey)
const value = typeof p.value === 'string' ? p.value : ''
const options = useMemo(() => {
const { upstream, other } = useMemo(() => {
const up = upstreamOf(p.nodeId, edges)
return nodes.filter((n) => up.has(n.id) && n.data?.nodeType === nodeType)
const same = nodes.filter((n) => n.data?.nodeType === nodeType)
return { upstream: same.filter((n) => up.has(n.id)), other: same.filter((n) => !up.has(n.id) && n.id !== p.nodeId) }
}, [nodes, edges, p.nodeId, nodeType])
const pick = useMemo(
() => resolvePick(p.nodeId, value, nodeType, rule, nodes, edges), [p.nodeId, value, nodeType, rule, nodes, edges],
)
// 选中的节点被删了、不是这一类、挪到了下游:下拉里照实多列一项(选不回去),不让它看着像「未选择」。
// 校验已经报了的,这一项照样列(看得出选的是什么),只是下面那句提示不再说
const stale = pick.kind === 'missing' || pick.kind === 'wrongType' || pick.kind === 'notUpstream' ? pick : null
// 分组列出画布上其余同类节点时,不在上游的那个本来就在「其余」一组里,不再另列。校验已经报了的,这一项照样列
// (看得出选的是什么),只是下面那句提示不再说
const stale = pick.kind === 'missing' || pick.kind === 'wrongType' || (pick.kind === 'notUpstream' && !text.groups) ? pick : null
const hint = reported ? ''
: pick.kind === 'missing' ? text.missingHint
: pick.kind === 'wrongType' ? text.wrongTypeHint(nodeTypeLabel(pick.node.data?.nodeType))
: pick.kind === 'notUpstream' ? text.notUpstreamHint
: pick.kind === 'none' ? text.none
: pick.kind === 'none' ? text.none ?? ''
: pick.kind === 'many' ? text.many(pick.count) : ''
const hintId = `${p.id}-hint`
const only = !value && pick.kind === 'target' ? String(pick.node.data?.label || pick.node.id) : null
const only = !value && pick.kind === 'target' ? nameOf(pick.node) : null
const option = (n: CanvasNode) => <option key={n.id} value={n.id}>{optionLabel(n)}</option>
return (
<>
<select id={p.id} className="field" value={value} disabled={p.disabled} aria-invalid={p.invalid || undefined}
aria-describedby={[p.describedBy, hint ? hintId : ''].filter(Boolean).join(' ') || undefined}
style={p.invalid || stale ? { borderColor: p.invalid ? 'var(--err)' : 'var(--warn)' } : undefined}
onChange={(e) => p.onChange(e.target.value || undefined)}>
<option value="">{text.auto(only)}</option>
{options.map((n) => <option key={n.id} value={n.id}>{nodeLabel(n)}</option>)}
<option value="">{text.auto(only, upstream.length)}</option>
{text.groups ? (
<>
{upstream.length > 0 && <optgroup label={text.groups.upstream}>{upstream.map(option)}</optgroup>}
{other.length > 0 && <optgroup label={text.groups.other}>{other.map(option)}</optgroup>}
</>
) : upstream.map(option)}
{stale && (
<option value={value} disabled>
{stale.kind === 'missing' ? text.missing(value)
: stale.kind === 'wrongType' ? text.wrongType(nodeLabel(stale.node)) : text.notUpstream(nodeLabel(stale.node))}
: stale.kind === 'wrongType' ? text.wrongType(optionLabel(stale.node)) : text.notUpstream(optionLabel(stale.node))}
</option>
)}
</select>
@@ -177,10 +235,12 @@ function NodePick({ p, fieldKey, nodeType, rule, text }: {
*/
export function nodePickField(def: {
key: string; label: string; nodeType: NodeType; rule?: PickRule; text: NodePickText; help?: string; advanced?: boolean
optionLabel?: (n: CanvasNode) => string
}): FieldDef {
return {
key: def.key, label: def.label, type: 'select', help: def.help, advanced: def.advanced,
render: (p) => <NodePick p={p} fieldKey={def.key} nodeType={def.nodeType} rule={def.rule ?? 'upstream'} text={def.text} />,
render: (p) => <NodePick p={p} fieldKey={def.key} nodeType={def.nodeType} rule={def.rule ?? 'upstream'} text={def.text}
optionLabel={def.optionLabel} />,
}
}
@@ -190,43 +250,56 @@ export interface TriStateText {
follow: string
on: string
off: string
/** 选中各态时下面那句说明:跟随的是哪一项设置、关掉之后会怎样 */
hint: { follow: string; on: string; off: string }
/** 「跟随设置」那一项附上设置里现在是开是关(设置还没取到时 on 为 null / undefined)。不给就只写 follow */
followNow?: (on: boolean | null | undefined) => string
/** 选中各态时下面那句说明:跟随的是哪一项设置、关掉之后会怎样。不给就不写(说明在字段的帮助里) */
hint?: { follow: string; on: string; off: string }
}
type Tri = 'follow' | 'on' | 'off'
const TRI: readonly Tri[] = ['follow', 'on', 'off']
function TriState({ p, label, text }: { p: FieldRenderProps; label: string; text: TriStateText }) {
export function TriState({ p, label, text, setting }: {
p: FieldRenderProps; label: string; text: TriStateText
/** 从设置的核查一组里取这一项现在的开关:给了才在「跟随设置」后面写出来 */
setting?: (v: VerifySettings) => boolean | null | undefined
}) {
// 只认真正的布尔:手写的 "false"、0 不当成关闭,按跟随设置显示(服务端校验会报出来)
const state: Tri = p.value === true ? 'on' : p.value === false ? 'off' : 'follow'
// 跟随设置写回 undefined:配置里不留这一项,和没配过的节点一样
const set = (t: Tri) => p.onChange(t === 'on' ? true : t === 'off' ? false : undefined)
const radio = useRadioGroup(TRI, state, set)
const settings = useVerifyDefaults(!!setting)
const now = setting && settings ? setting(settings) : undefined
const hintId = `${p.id}-hint`
return (
<>
{/* 字段标签的 for 指不到一组按钮,组名另写在 aria-label 上 */}
<div id={p.id} role="radiogroup" aria-label={label} data-tri-state={state}
aria-describedby={[hintId, p.describedBy].filter(Boolean).join(' ')}
className="inline-flex max-w-full flex-wrap rounded-md border p-px">
aria-describedby={[text.hint ? hintId : '', p.describedBy].filter(Boolean).join(' ') || undefined}
className="inline-flex max-w-full flex-wrap gap-0.5 rounded-md border p-0.5">
{TRI.map((t) => (
<button key={t} type="button" {...radio(t)} disabled={p.disabled} data-tri={t} onClick={() => set(t)}
className={clsx('rounded px-2 py-0.5 text-2xs', state === t ? 'bg-accent-soft text-fg' : 'text-faint hover:text-dim')}>
{text[t]}
// 不折行:「跟随设置(当前开启)」比「开启」长得多,太窄时整段换到下一行
className={clsx('whitespace-nowrap rounded px-2 py-0.5 text-2xs transition-colors',
state === t ? 'bg-accent-soft font-medium text-fg' : 'text-dim hover:bg-hover')}>
{t === 'follow' && text.followNow ? text.followNow(now) : text[t]}
</button>
))}
</div>
<div id={hintId} className="mt-1 text-2xs leading-snug text-faint" data-tri-hint={state}>{text.hint[state]}</div>
{text.hint && <div id={hintId} className="mt-1 text-2xs leading-snug text-faint" data-tri-hint={state}>{text.hint[state]}</div>}
</>
)
}
/** 跟随设置 / 开启 / 关闭:配置里没有这一项(或 null)/ true / false */
export function triStateField(def: { key: string; label: string; text: TriStateText; advanced?: boolean }): FieldDef {
export function triStateField(def: {
key: string; label: string; text: TriStateText; advanced?: boolean; help?: string
setting?: (v: VerifySettings) => boolean | null | undefined
}): FieldDef {
return {
key: def.key, label: def.label, type: 'select', advanced: def.advanced,
render: (p) => <TriState p={p} label={def.label} text={def.text} />,
key: def.key, label: def.label, type: 'select', advanced: def.advanced, help: def.help,
render: (p) => <TriState p={p} label={def.label} text={def.text} setting={def.setting} />,
}
}
@@ -243,17 +316,17 @@ export interface VerifyModelText {
/** 模型框的占位:接入也没选 / 选了接入 */
modelFollow: string
modelDefault: string
/** 取值链的说明,常显在模型框下面 */
help: string
/** 取值链的说明,常显在模型框下面。不给就不写(说明在字段的帮助里) */
help?: string
byModel: (model: string, found: { name: string; enabled: boolean } | null | undefined, fallback: string | null | undefined) => string
/** 和被核查的模型是同一个 */
same: (model: string) => string
/** 和被核查的模型是同一个(给了 subject 时用) */
same?: (model: string) => string
}
/** 被核查的那个模型:节点上明确写着的模型名;说不准(没写、跟随默认)时为 null,不提醒 */
export type SubjectModel = (nodeId: string, config: Record<string, any>, graph: { nodes: FlowNode[]; edges: Edge[] }) => string | null
export type SubjectModel = (nodeId: string, config: Record<string, any>, graph: { nodes: readonly CanvasNode[]; edges: readonly CanvasEdge[] }) => string | null
function ProviderPick({ p, text }: { p: FieldRenderProps; text: VerifyModelText }) {
export function ProviderPick({ p, text }: { p: FieldRenderProps; text: VerifyModelText }) {
const providers = useCatalog((s) => s.providers)
const provider = typeof p.value === 'string' ? p.value : ''
const model = typeof p.config.model === 'string' ? p.config.model.trim() : ''
@@ -272,7 +345,7 @@ function ProviderPick({ p, text }: { p: FieldRenderProps; text: VerifyModelText
)
}
function ModelInput({ p, fieldKey, text, subject }: {
export function ModelInput({ p, fieldKey, text, subject }: {
p: FieldRenderProps; fieldKey: string; text: VerifyModelText; subject?: SubjectModel
}) {
const providers = useCatalog((s) => s.providers)
@@ -292,7 +365,7 @@ function ModelInput({ p, fieldKey, text, subject }: {
const fallback = enabled.find((x) => x.kind !== 'mock') ?? enabled[0]
const byModelHint = byModel && providers.length > 0
? text.byModel(name, owner && { name: owner.provider.name, enabled: owner.enabled }, fallback?.name) : ''
const same = !!name && !!against && name === against && !reported
const same = !!name && !!against && name === against && !reported && !!text.same
return (
<>
<input id={p.id} className="field mono text-xs" list={`${p.id}-models`} value={model} spellCheck={false}
@@ -309,10 +382,10 @@ function ModelInput({ p, fieldKey, text, subject }: {
)}
{same && (
<div className="mt-1 text-2xs leading-snug" style={{ color: 'var(--st-waiting)' }} data-verify-same-model="">
{text.same(name)}
{text.same!(name)}
</div>
)}
<div className="mt-1 text-2xs leading-snug text-faint">{text.help}</div>
{text.help && <div className="mt-1 text-2xs leading-snug text-faint">{text.help}</div>}
</>
)
}
+7 -5
View File
@@ -3350,9 +3350,8 @@ export const DUAL_CHECK_TEXT = {
// ---- 检查器(nodeDefs 的 dual_check、canvas/DualCheckFields.tsx)
/**
* 字段名,键就是配置键。服务端校验和报错引用的是同一套叫法(schema.py 的 DUAL_CHECK_FIELD_LABEL,check-verify 在
* 服务端合入后逐项比对):问题挂在字段下面、出现在问题面板里时,人才找得到是哪一项。模型两项照「裁判模型 · 接入」
* 「裁判模型」的写法
* 字段名,键就是配置键。服务端校验和报错引用的是同一套叫法(engine/labels.py 的 DUAL_CHECK_FIELD_LABEL,check-verify
* 逐项比对):问题挂在字段下面、出现在问题面板里时,人才找得到是哪一项。模型两项照「裁判模型 · 接入」「裁判模型」的写法
*/
field: {
card: '口径卡',
@@ -3388,7 +3387,10 @@ export const DUAL_CHECK_TEXT = {
cardOption: (label: string, caliber?: string | null) => (caliber && caliber !== label ? `${label}(${caliber})` : label),
cardGone: (id: string) => `${id}(不在画布上)`,
cardNotUpstream: '所选口径卡不在本节点上游,运行到本节点时还没有它的结果。请把它连接到本节点之前',
cardMissing: (id: string) => `口径卡「${id}」不在画布上,请重新选择`,
cardMissingHint: '所选口径卡已不在画布上,请重新选择',
/** 选中的节点在画布上,但不是口径卡(助手改图、导入 JSON 会写出这种配置):下拉里那一项、下面那句提示 */
cardWrongType: (label: string) => `${label}(不是口径卡)`,
cardWrongTypeHint: (type: string) => `所选节点是「${type}」节点,不是口径卡,请重新选择`,
metricsAll: '全部指标',
metricsDefault: '未指定:复算全部输入来自查询的指标',
metricsPicked: (n: number, total: number) => `已选 ${n} / ${total} 个指标`,
@@ -3422,7 +3424,7 @@ export const DUAL_CHECK_TEXT = {
},
tolerancePlaceholder: (v: number | null | undefined) => (v == null ? '跟随设置' : `跟随设置(${v})`),
toleranceInvalid: '请填写不小于 0 的数',
/** 选项以服务端报错里的短说法(schema.py 的 DUAL_CHECK_ON_REJECT_LABEL)开头,后面可以带一句用途 */
/** 选项以服务端报错里的短说法(engine/labels.py 的 DUAL_CHECK_ON_REJECT_LABEL)开头,后面可以带一句用途 */
onReject: { degrade: '计入缺口、出具降档(默认)', fail: '判为失败,运行终止' } as Record<string, string>,
onRejectUnknown: (v: string) => `${v}:无法识别的值,请从上方选项中选择`,
enabled: { follow: '跟随设置', on: '开启', off: '关闭' },
+45 -24
View File
@@ -24,7 +24,8 @@
//
// 页面都跑在预览页(/preview.html?syn=…,用例在 frontend/src/dev/fixtures/dual_check.tsx,夹具 dual_check.json 两边
// 共用)和设置页上,接口一律 page.route 伪造:完整对照的工件、审批回复、设置的读写都在浏览器里答,别的非 GET 拦掉,
// 不碰真实的写接口。文案对照那一节不开浏览器:读 backend/app/api/settings.py,terms.ts 借 vite 转译后直接 import。
// 不碰真实的写接口。文案对照那一节不开浏览器:读 backend/app/api/settings.py、backend/app/engine/labels.py,terms.ts 借 vite
// 转译后直接 import。
//
// node scripts/check-verify.mjs
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-verify.mjs
@@ -126,26 +127,23 @@ await section('核查设置的叫法和服务端一字不差', async () => {
})
await section('双盲复算检查器的叫法和服务端校验报错一致', async () => {
// 校验问题挂在字段下方、出现在问题面板里时,报错引用的是 schema.py 的 DUAL_CHECK_FIELD_LABEL:检查器上得是同一个名字,
// 人才找得到是哪一项。处置的取值:报错里用短说法,下拉的选项以它开头、后面可以带一句用途(labels.py 的约定)
// 校验问题挂在字段下方、出现在问题面板里时,报错引用的是 labels.py 的 DUAL_CHECK_FIELD_LABEL(期 4 从 schema.py 挪过去,
// field_label 按节点类型也说这一套):检查器上得是同一个名字,人才找得到是哪一项。处置的取值:报错里用短说法,下拉的
// 选项以它开头、后面可以带一句用途(labels.py 的约定)
const { DUAL_CHECK_TEXT: D } = await import(await load('/src/lib/terms.ts'))
const KEYS = ['card', 'metrics', 'definitions', 'provider', 'model', 'tolerance', 'on_reject', 'enabled']
check('检查器的字段名按配置键放在 DUAL_CHECK_TEXT.field', KEYS.every((k) => typeof D.field?.[k] === 'string' && D.field[k]),
KEYS.filter((k) => !D.field?.[k]).join('、'))
check('……模型的两项照「裁判模型 · 接入」「裁判模型」的叫法', D.field.provider === '盲算模型 · 接入' && D.field.model === '盲算模型',
`${D.field.provider} / ${D.field.model}`)
const py = readFileSync(`${root}backend/app/engine/schema.py`, 'utf8')
const block = py.match(/^DUAL_CHECK_FIELD_LABEL = \{([\s\S]*?)^\}/m)?.[1]
if (block == null) {
// 双盲复算的服务端在另一个工作包里,合入之前 schema.py 还没有这一组:合入后这一节就逐项比对
check('schema.py 还没有 DUAL_CHECK_FIELD_LABEL(双盲复算服务端合入后逐项比对)', true)
return
}
const labels = Object.fromEntries([...block.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]]))
const py = readFileSync(`${root}backend/app/engine/labels.py`, 'utf8')
const block = py.match(/^DUAL_CHECK_FIELD_LABEL: dict\[str, str\] = \{([\s\S]*?)^\}/m)?.[1]
check('从 labels.py 读出了 DUAL_CHECK_FIELD_LABEL', block != null)
const labels = Object.fromEntries([...(block ?? '').matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]]))
const wrong = KEYS.filter((k) => labels[k] !== D.field[k])
check('每个字段的叫法和 DUAL_CHECK_FIELD_LABEL 一字不差', !wrong.length && Object.keys(labels).length === KEYS.length,
wrong.map((k) => `${k}:${D.field[k]} ≠ ${labels[k]}`).join(';'))
const opts = py.match(/^DUAL_CHECK_ON_REJECT_LABEL = \{([^}]*)\}/m)?.[1] ?? ''
const opts = py.match(/^DUAL_CHECK_ON_REJECT_LABEL: dict\[str, str\] = \{([^}]*)\}/m)?.[1] ?? ''
const short = Object.fromEntries([...opts.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]]))
check('「未采用原流程时」的选项以服务端的短说法开头', Object.keys(short).length === 2
&& Object.entries(short).every(([k, v]) => D.onReject?.[k]?.startsWith(v)), JSON.stringify(short))
@@ -931,11 +929,32 @@ await section('检查器:每一项都写进节点配置,节点卡片的摘
check('……标签指向这个下拉', await page.locator('[data-field="card"] label').getAttribute('for') === await card.getAttribute('id'))
await card.selectOption('card2')
check('选另一张:写进 card', (await config(page)).card === 'card2', JSON.stringify(await config(page)))
check('……不在上游的照实提醒', (await text(page.locator('[data-dual-card-warn]'))).includes('不在本节点上游'))
check('……不在上游的照实提醒', (await text(page.locator('[data-node-pick-hint="notUpstream"]'))).includes('不在本节点上游'))
const swapped = await summaryBecomes(page, '复算「月报口径卡」 · 全部 1 个指标')
check('……摘要换成这张卡', swapped.ok, swapped.seen)
await card.selectOption('')
check('选回默认:card 去掉,不留空串', !('card' in (await config(page))) || (await config(page)).card === undefined, JSON.stringify(await config(page)))
// 选口径卡用的是和反方审稿共用的控件(verifyFields.tsx 的 NodePick):写着的卡被删了、写的不是口径卡(助手改图、导入
// JSON 会写出这种配置)都照实列在下拉里(选不回去)、下面说一句,不让它看着像「默认」
const setCard = (value) => page.evaluate((v) => {
const s = window.__studio.getState()
const n = s.nodes.find((x) => x.id === 'dc')
s.updateNode('dc', { config: { ...n.data.config, card: v } })
}, value)
const selected = () => card.evaluate((el) => el.options[el.selectedIndex]?.textContent)
await setCard('ghost')
await page.waitForTimeout(100)
check('写着的卡已不在画布上:下拉照实标出,下面说一句', await selected() === 'ghost(不在画布上)'
&& (await text(page.locator('[data-node-pick-hint="missing"]'))) === '所选口径卡已不在画布上,请重新选择', await selected())
await setCard('fetch')
await page.waitForTimeout(100)
const wrongType = page.locator('[data-node-pick-hint="wrongType"]')
check('写的不是口径卡:下拉标「不是口径卡」,提示说清它是哪类节点,不说成不在画布上', await selected() === '取数(不是口径卡)'
&& await wrongType.count() === 1 && (await text(wrongType)).startsWith('所选节点是「') && (await text(wrongType)).endsWith('」节点,不是口径卡,请重新选择')
&& await page.locator('[data-node-pick-hint="missing"]').count() === 0, `${await selected()} · ${await wrongType.count() ? await text(wrongType) : '没有提示'}`)
await card.selectOption('')
check('……选回默认:提示收起、card 去掉', await page.locator('[data-node-pick-hint]').count() === 0 && (await config(page)).card === undefined,
JSON.stringify(await config(page)))
// 指标:逐个勾;全部勾着是缺省,不写;最后一个不让取消
const opts = page.locator('[data-dual-metric-option]')
@@ -973,16 +992,18 @@ await section('检查器:每一项都写进节点配置,节点卡片的摘
// 启用:跟随设置 / 开启 / 关闭
const enabled = page.locator('[data-field="enabled"] [role="radiogroup"]')
check('启用用的是和反方审稿共用的三态控件,组名是字段名', (await enabled.getAttribute('data-tri-state')) === 'follow'
&& (await enabled.getAttribute('aria-label')) === D.field.enabled)
check('启用:三态,跟随设置写出设置里眼下是开着的', (await enabled.locator('[role="radio"]').allInnerTexts()).join('|') === '跟随设置(当前开启)|开启|关闭'
&& await enabled.locator('[role="radio"][aria-checked="true"]').getAttribute('data-value') === 'follow')
await enabled.locator('[data-value="off"]').click()
&& await enabled.locator('[role="radio"][aria-checked="true"]').getAttribute('data-tri') === 'follow')
await enabled.locator('[data-tri="off"]').click()
const off = await summaryBecomes(page, { suffix: ' · 已关闭' })
check('关闭:enabled 为 false,摘要标「已关闭」', (await config(page)).enabled === false && off.ok, off.seen)
await enabled.locator('[data-value="off"]').focus()
await enabled.locator('[data-tri="off"]').focus()
await page.keyboard.press('ArrowLeft')
check('键盘方向键换选项:开启是 true', (await config(page)).enabled === true
&& await page.evaluate(() => document.activeElement?.getAttribute('data-value')) === 'on')
await enabled.locator('[data-value="follow"]').click()
&& await page.evaluate(() => document.activeElement?.getAttribute('data-tri')) === 'on')
await enabled.locator('[data-tri="follow"]').click()
check('跟随设置:enabled 去掉,不留 null', (await config(page)).enabled === undefined, JSON.stringify(await config(page)))
// 未采用原流程时
@@ -1007,7 +1028,7 @@ await section('检查器:每一项都写进节点配置,节点卡片的摘
check('模型:写进 model', (await config(page)).model === 'model-b')
await provider.selectOption('')
check('只填了模型:接入留空那一项改叫「按模型名找接入」,写明按模型名用哪个接入', (await provider.locator('option').first().innerText()) === '按模型名找接入'
&& (await text(page.locator('[data-dual-by-model]'))).includes('将按模型名使用接入「云端接入」') && (await config(page)).provider === undefined)
&& (await text(page.locator('[data-verify-by-model]'))).includes('将按模型名使用接入「云端接入」') && (await config(page)).provider === undefined)
const tol = page.locator('[data-field="tolerance"] input')
check('允许的相对误差:占位写出设置里的值', await tol.getAttribute('placeholder') === '跟随设置(0.000001)', await tol.getAttribute('placeholder'))
await tol.fill('0.001')
@@ -1035,20 +1056,20 @@ await section('检查器:上游有几张口径卡时必须选;「跟随设
await page.waitForFunction(() => document.querySelector('[data-field="card"] select option')?.textContent?.includes('2 张'))
check('上游两张:留空那一项写明要选,不说「建议」', (await card.locator('option').first().innerText()) === '请选择要复算的口径卡(上游有 2 张)'
&& !(await text(page.locator('[data-field="card"]'))).includes('建议'), await card.locator('option').first().innerText())
check('……下面用提醒色写明必须选一张', (await text(page.locator('[data-dual-card-warn]'))) === '上游有 2 张口径卡,请选择要复算的那一张')
check('……下面用提醒色写明必须选一张', (await text(page.locator('[data-node-pick-hint="many"]'))) === '上游有 2 张口径卡,请选择要复算的那一张')
const many = await summaryBecomes(page, '复算上游的口径卡 · 上游有 2 张,请指定')
check('……节点卡片的摘要也这么说', many.ok, many.seen)
await card.selectOption('card')
check('……选了一张:提醒收起', await page.locator('[data-dual-card-warn]').count() === 0 && (await config(page)).card === 'card')
check('……选了一张:提醒收起', await page.locator('[data-node-pick-hint]').count() === 0 && (await config(page)).card === 'card')
// 去设置里关掉双盲复算、改了相对误差,再回到画布打开这个节点:「跟随设置」写的是新的值
const enabled = page.locator('[data-field="enabled"] [role="radio"][data-value="follow"]')
const enabled = page.locator('[data-field="enabled"] [role="radio"][data-tri="follow"]')
check('打开时:跟随设置(当前开启)', (await text(enabled)) === '跟随设置(当前开启)', await text(enabled))
verifyNow = { ...fx.verify, dual_check: { ...fx.verify.dual_check, enabled: false, tolerance: 0.01 } }
await page.evaluate(() => window.__studio.setState({ selectedId: null }))
await page.waitForSelector('[data-field="enabled"]', { state: 'detached' })
await page.evaluate(() => window.__studio.setState({ selectedId: 'dc' }))
await page.waitForFunction(() => document.querySelector('[data-field="enabled"] [data-value="follow"]')?.textContent === '跟随设置(当前关闭)', null, { timeout: 3000 })
await page.waitForFunction(() => document.querySelector('[data-field="enabled"] [data-tri="follow"]')?.textContent === '跟随设置(当前关闭)', null, { timeout: 3000 })
.catch(() => {})
check('再打开:重取设置,写当前关闭', (await text(enabled)) === '跟随设置(当前关闭)', await text(enabled))
await page.locator('button', { hasText: '高级选项' }).click()
@@ -1071,7 +1092,7 @@ await section('检查器:360px 栏、390px 窄屏不横向溢出,深浅色
check(`${width}px(${theme}):检查器不横向溢出`, w.page <= 0 && w.panel <= 0 && w.body <= 0, JSON.stringify(w))
const c = await page.evaluate(() => {
const base = getComputedStyle(document.querySelector('[data-dual-inspector]')).backgroundColor
const el = document.querySelector('[data-dual-by-model]')
const el = document.querySelector('[data-verify-by-model]')
const radio = document.querySelector('[data-field="enabled"] [role="radio"][aria-checked="true"]')
return { base, hint: el && getComputedStyle(el).color, radio: radio && getComputedStyle(radio).color,
radioBg: radio && getComputedStyle(radio).backgroundColor }