Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions tests/console_decision_inbox_gaps_validation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// M09 decision-inbox gaps: server blocker codes, approved/rejected outcome and
// the ?page=decisions&decision=<id> deep link. Synthetic fixtures only.
import assert from "node:assert/strict";
import { test } from "node:test";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { build } from "../web/strategy-switch-console/frontend/node_modules/esbuild/lib/main.js";

const root = new URL("../web/strategy-switch-console/frontend/", import.meta.url).pathname;
const pagePath = new URL("../web/strategy-switch-console/frontend/src/pages/decisions/DecisionsPage.tsx", import.meta.url);
const dir = await mkdtemp(join(tmpdir(), "decision-gaps-"));
let ui;
try {
const outfile = join(dir, "render.cjs");
await build({
stdin: {
resolveDir: root,
contents: `import {createElement as h} from 'react';
import {renderToStaticMarkup} from 'react-dom/server'; import {I18nProvider} from './src/i18n';
import DecisionsPage from './src/pages/decisions/DecisionsPage';
export * as helpers from './src/pages/decisions/helpers';
export {parseRoute, routeToSearch} from './src/app/router';
export function render(item, language) {
globalThis.__gapItem = item; globalThis.__gapUseApi = 0;
return renderToStaticMarkup(h(I18nProvider, {language}, h(DecisionsPage, {decision: null})));
}`,
},
bundle: true, platform: "node", format: "cjs", outfile, jsx: "automatic",
loader: { ".css": "empty" }, define: { "import.meta.env": "{}" }, logLevel: "silent",
plugins: [{ name: "mock", setup(b) {
b.onLoad({ filter: /\/lib\/hooks\.ts$/ }, () => ({ loader: "js", contents: `export function useApi() {
const n = globalThis.__gapUseApi++;
return { phase: 'ready', data: n === 0 ? { status: 'ok', items: [globalThis.__gapItem], sources: [] } : n === 1 ? { admin: true } : {}, reload() {} };
} export const createRequestGate = () => ({ begin: () => 1, isCurrent: () => true });` }));
b.onLoad({ filter: /\/pages\/decisions\/DecisionsPage\.tsx$/ }, async ({ path }) => ({ loader: "tsx",
contents: (await readFile(path, "utf8")).replace("useState<string | null>(null);\n const [busy", "useState<string | null>(globalThis.__gapItem.id);\n const [busy") }));
} }],
});
ui = createRequire(import.meta.url)(outfile);
} finally {
await rm(dir, { recursive: true, force: true });
}

const base = {
id: "owner_decision:synthetic-ticket", short_id: "IN-GAP", category: "owner_decision", priority: "normal",
created_at: null, expires_at: null, evidence_freshness: { status: "ready", as_of: null }, status: "pending",
affects_live: false, diff: [], title_key: "decisions.owner_decision", account_alias: "synthetic", platform: "ibkr",
decision_binding: { kind: "owner", subject_id: "synthetic-ticket", material_sha256: "a".repeat(64) },
allowed_actions: ["approve_limited_live_canary", "keep_parked"],
};
const COPY = {
zh: { stale: "支持资料已过时,当前无法批准。", notAllowed: "服务端当前不允许审批。", other: "当前不可审批。",
blocked: "暂不可审批", approved: "已批准", rejected: "已拒绝", decided: "已处理", pending: "待处理" },
en: { stale: "Supporting data is stale; approval is unavailable.", notAllowed: "The server does not allow approval right now.",
other: "Approval is unavailable.", blocked: "Approval unavailable",
approved: "Approved", rejected: "Rejected", decided: "Decided", pending: "Pending" },
};
const blockerList = html => {
const at = html.indexOf('class="cockpit-blockers"');
return at < 0 ? null : html.slice(at, html.indexOf("</ul>", at));
};
const approveDisabled = html => /<button type="button" class="button button-primary" disabled="">/.test(html);
const approveEnabled = html => /<button type="button" class="button button-primary">/.test(html);
const rowBadge = html => {
const start = html.indexOf('class="inbox-item');
const row = html.slice(start, html.indexOf("</button>", start));
return row.slice(row.lastIndexOf('<span class="badge'));
};
const detailHead = html => {
const at = html.indexOf('class="decision-badges"');
return html.slice(at, html.indexOf("</div>", at));
};

test("gap 1: inbox blocking_reason_codes render as bilingual blocker copy", () => {
for (const language of ["zh", "en"]) {
const c = COPY[language];
const html = ui.render({ ...base, actionable: false,
blocking_reason_codes: ["decision_evidence_stale", "decision_future_code", 42, "<script>x</script>", "decision_evidence_stale"] }, language);
const list = blockerList(html);
assert.ok(list, `${language}: blocker list renders`);
assert.ok(list.includes(`aria-label="${c.blocked}"`), `${language}: list is labelled`);
assert.equal((list.match(/<li>/g) || []).length, 2, `${language}: duplicates collapse; unknown and malformed codes share one generic line`);
assert.ok(list.includes(c.stale), `${language}: known code maps to cockpit.blocker copy`);
assert.ok(list.includes(`<li>${c.other}</li>`), `${language}: unknown and malformed codes fall back to generic copy`);
assert.ok(!html.includes("decision_future_code"), `${language}: an unknown machine code never renders raw`);
assert.ok(!html.includes("<code>"), `${language}: no raw code element`);
assert.ok(!html.includes("script"), `${language}: malformed code text never reaches markup`);
assert.ok(!html.includes(c.notAllowed), `${language}: specific codes replace the generic server note`);
assert.ok(approveDisabled(html), `${language}: server-blocked item keeps approve disabled even if allowed_actions lists it`);
}
});

test("gap 1: fail-closed actionable handling", () => {
for (const language of ["zh", "en"]) {
const c = COPY[language];
const falseNoCodes = ui.render({ ...base, actionable: false, blocking_reason_codes: [] }, language);
assert.ok(blockerList(falseNoCodes)?.includes(c.notAllowed), `${language}: actionable=false without codes says not allowed`);
assert.ok(approveDisabled(falseNoCodes));
const trueWithCodes = ui.render({ ...base, actionable: true, blocking_reason_codes: ["decision_expired"] }, language);
assert.ok(blockerList(trueWithCodes), `${language}: codes win over actionable=true`);
assert.ok(approveDisabled(trueWithCodes));
const malformedList = ui.render({ ...base, actionable: true, blocking_reason_codes: "decision_expired" }, language);
assert.ok(blockerList(malformedList)?.includes(`<li>${c.other}</li>`), `${language}: non-array codes are blocked`);
assert.ok(approveDisabled(malformedList));
const malformedFlag = ui.render({ ...base, actionable: "yes", blocking_reason_codes: [] }, language);
assert.ok(blockerList(malformedFlag), `${language}: a non-boolean actionable never implies actionable`);
assert.ok(approveDisabled(malformedFlag));
const clean = ui.render({ ...base, actionable: true, blocking_reason_codes: [] }, language);
assert.equal(blockerList(clean), null, `${language}: actionable item shows no blocker list`);
assert.ok(approveEnabled(clean), `${language}: actionable item keeps approve enabled`);
}
});

test("gap 1: legacy items without the fields keep current behaviour", () => {
for (const language of ["zh", "en"]) {
const c = COPY[language];
const open = ui.render(base, language);
assert.equal(blockerList(open), null);
assert.ok(approveEnabled(open), `${language}: legacy allowed item stays enabled`);
const closed = ui.render({ ...base, allowed_actions: [] }, language);
assert.equal(blockerList(closed), null);
assert.ok(closed.includes(`<p class="decision-note">${c.notAllowed}</p>`), `${language}: generic note stays for legacy items`);
}
const { inboxBlockers } = ui.helpers;
assert.equal(inboxBlockers({}), null);
assert.equal(inboxBlockers(null), null);
assert.deepEqual(inboxBlockers({ actionable: true, blocking_reason_codes: [] }), { blocked: false, blockers: [] });
assert.deepEqual(inboxBlockers({ blocking_reason_codes: [] }), { blocked: true, blockers: ["cockpit.blocker.other"] },
"codes without actionable === true stay blocked");
});

test("gap 2: decided items show approved or rejected from decision_state.action", () => {
const cases = [
[{ status: "decided", action: "approve_limited_live_canary" }, "approved"],
[{ status: "decided", action: "approve" }, "approved"],
[{ status: "decided", action: "accept" }, "approved"],
[{ status: "decided", action: "keep_parked" }, "rejected"],
[{ status: "decided", action: "reject" }, "rejected"],
[{ status: "decided", action: null }, "decided"],
[{ status: "decided", action: "something_new" }, "decided"],
];
for (const language of ["zh", "en"]) {
const c = COPY[language];
for (const [state, key] of cases) {
const html = ui.render({ ...base, actionable: false, blocking_reason_codes: ["decision_already_decided"], decision_state: state }, language);
assert.ok(rowBadge(html).includes(`>${c[key]}</span>`), `${language}: list badge for ${JSON.stringify(state)} is ${key}`);
assert.ok(detailHead(html).includes(`>${c[key]}</span>`), `${language}: detail badge for ${JSON.stringify(state)} is ${key}`);
assert.ok(!html.includes('class="inbox-actions"'), `${language}: a decided item offers no actions`);
}
const pending = ui.render({ ...base, decision_state: { status: "pending", action: null, decided_at: null } }, language);
assert.ok(rowBadge(pending).includes(`>${c.pending}</span>`), `${language}: pending stays pending`);
const absent = ui.render(base, language);
assert.ok(rowBadge(absent).includes(`>${c.pending}</span>`), `${language}: absent decision_state keeps item.status`);
}
const { decisionStatusKey } = ui.helpers;
assert.equal(decisionStatusKey({ status: "decided" }, false), "decided");
assert.equal(decisionStatusKey({ status: "decided", decision_state: { status: "decided", action: "reject" } }, false), "rejected");
assert.equal(decisionStatusKey({ status: "pending", decision_state: "garbage" }, false), "pending");
assert.equal(decisionStatusKey({ status: "pending" }, true), "expired");
assert.equal(decisionStatusKey({ status: "expired", decision_state: { status: "decided", action: "accept" } }, true), "approved");
});

test("deep link: ?page=decisions&decision=<id> is parsed, consumed once and restores the queue", async () => {
const route = ui.parseRoute("?page=decisions&decision=owner_decision%3Asynthetic");
assert.equal(route.page, "decisions");
assert.equal(route.decision, "owner_decision:synthetic");
assert.equal(ui.routeToSearch(route), "?page=decisions&decision=owner_decision%3Asynthetic");
assert.equal(ui.routeToSearch({ ...route, page: "overview" }), "", "the decision param only belongs to the decisions page");

const { deepLinkTarget } = ui.helpers;
const items = [{ id: "a:1" }, { id: "b:2" }];
assert.equal(deepLinkTarget(null, null, true, items), null, "no deep link, nothing to restore");
assert.equal(deepLinkTarget("b:2", null, false, items), null, "waits until the inbox is ready");
assert.equal(deepLinkTarget("c:3", null, true, items), null, "an unknown id is not consumed or selected");
assert.equal(deepLinkTarget("b:2", null, true, items), "b:2", "a known id is restored once the inbox arrives");
assert.equal(deepLinkTarget("b:2", "b:2", true, items), null, "an applied link is not re-applied");
assert.equal(deepLinkTarget("a:1", "b:2", true, items), "a:1", "a new link is applied");

// The page effect uses the helper and restores the tab and filter that could hide the target.
const source = await readFile(pagePath, "utf8");
const effect = source.slice(source.indexOf("const target = deepLinkTarget("), source.indexOf("}, [decision, inbox.phase, items]);"));
assert.match(effect, /deepLinkTarget\(decision, consumedDeepLink\.current, inbox\.phase === "ready", items\)/);
for (const step of ["consumedDeepLink.current = target;", 'setView("pending");', 'setCategory("all");', "setSelectedId(target);"])
assert.ok(effect.includes(step), `deep-link effect performs ${step}`);
});
2 changes: 1 addition & 1 deletion web/strategy-switch-console/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"build": "tsc --noEmit && vite build && node scripts/check-budget.mjs",
"sync:v2-assets": "node scripts/sync-v2-asset-map.mjs",
"test:operations": "node ../../../tests/account_config_label_validation.mjs && node --experimental-strip-types ../../../tests/console_v2_operations_validation.mjs && node --experimental-strip-types ../../../tests/console_v2_regressions_validation.mjs && node --experimental-strip-types ../../../tests/console_v2_preferences_accounts_validation.mjs && node ../../../tests/console_runtime_control_result_ui_validation.mjs && node ../../../tests/console_page_load_boundary_validation.mjs && node --test ../../../tests/fe8b_fix03_ui_validation.mjs ../../../tests/console_notification_receipts_ui_validation.mjs && npm run test:performance && node --test ../../../tests/console_execution_evidence_validation.mjs && node ../../../tests/console_strategy_execution_validation.mjs && node --experimental-strip-types ../../../tests/console_binance_wallet_cards_validation.mjs && node ../../../tests/console_tabs_scope_sources_ui_validation.mjs && node ../../../tests/console_shell_unavailable_source_ui_validation.mjs && node ../../../tests/console_cockpit_decision_validation.mjs && node ../../../tests/console_frontend_i18n_validation.mjs && node ../../../tests/console_credential_rotation_ui_validation.mjs && node ../../../tests/console_style_guards_validation.mjs && node ../../../tests/console_tokens_css_validation.mjs && node --experimental-strip-types ../../../tests/console_desk_format_validation.mjs && node --experimental-strip-types ../../../tests/console_desk_mock_validation.mjs && node --experimental-strip-types ../../../tests/console_desk_shell_validation.mjs && node --experimental-strip-types ../../../tests/console_desk_pages_validation.mjs && node --experimental-strip-types ../../../tests/console_desk_p1_acceptance_validation.mjs && node --experimental-strip-types ../../../tests/console_desk_benchmarks_validation.mjs && node --experimental-strip-types ../../../tests/console_desk_loading_retry_validation.mjs && node --experimental-strip-types --test ../../../tests/console_desk_delivery_validation.mjs",
"test:operations": "node ../../../tests/account_config_label_validation.mjs && node --experimental-strip-types ../../../tests/console_v2_operations_validation.mjs && node --experimental-strip-types ../../../tests/console_v2_regressions_validation.mjs && node --experimental-strip-types ../../../tests/console_v2_preferences_accounts_validation.mjs && node ../../../tests/console_runtime_control_result_ui_validation.mjs && node ../../../tests/console_page_load_boundary_validation.mjs && node --test ../../../tests/fe8b_fix03_ui_validation.mjs ../../../tests/console_notification_receipts_ui_validation.mjs && npm run test:performance && node --test ../../../tests/console_execution_evidence_validation.mjs && node ../../../tests/console_strategy_execution_validation.mjs && node --experimental-strip-types ../../../tests/console_binance_wallet_cards_validation.mjs && node ../../../tests/console_tabs_scope_sources_ui_validation.mjs && node ../../../tests/console_shell_unavailable_source_ui_validation.mjs && node ../../../tests/console_cockpit_decision_validation.mjs && node ../../../tests/console_frontend_i18n_validation.mjs && node ../../../tests/console_credential_rotation_ui_validation.mjs && node ../../../tests/console_style_guards_validation.mjs && node ../../../tests/console_tokens_css_validation.mjs && node --experimental-strip-types ../../../tests/console_desk_format_validation.mjs && node --experimental-strip-types ../../../tests/console_desk_mock_validation.mjs && node --experimental-strip-types ../../../tests/console_desk_shell_validation.mjs && node --experimental-strip-types ../../../tests/console_desk_pages_validation.mjs && node --experimental-strip-types ../../../tests/console_desk_p1_acceptance_validation.mjs && node --experimental-strip-types ../../../tests/console_desk_benchmarks_validation.mjs && node --experimental-strip-types ../../../tests/console_desk_loading_retry_validation.mjs && node --experimental-strip-types --test ../../../tests/console_desk_delivery_validation.mjs && node --experimental-strip-types --test ../../../tests/console_todo_state_validation.mjs ../../../tests/console_load_lifecycle_validation.mjs ../../../tests/promotion_queue_partial_validation.mjs ../../../tests/console_decision_inbox_gaps_validation.mjs",
"check:style-guards": "node scripts/check-style-guards.mjs",
"dev:synthetic": "node scripts/dev-synthetic.mjs",
"test:performance": "node --experimental-strip-types ../../../tests/console_performance_math_validation.mjs && node --experimental-strip-types ../../../tests/console_performance_data_validation.mjs && node --experimental-strip-types ../../../tests/console_calendar_pending_ui_validation.mjs && node ../../../tests/website_currency_ranking_validation.mjs && node ../../../tests/website_summary_request_validation.mjs && node ../../../tests/console_compact_summary_validation.mjs && node ../../../tests/native_daily_gap_ui_validation.mjs"
Expand Down
2 changes: 2 additions & 0 deletions web/strategy-switch-console/frontend/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,8 @@ export const en: Record<CoreMessageKey, string> = {
"decisions.status.pending": "Pending",
"decisions.status.decided": "Decided",
"decisions.status.expired": "Expired",
"decisions.status.approved": "Approved",
"decisions.status.rejected": "Rejected",
"decisions.question": "Your decision",
"decisions.diff_title": "Before → after",
"decisions.col.field": "Field",
Expand Down
2 changes: 2 additions & 0 deletions web/strategy-switch-console/frontend/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@ export const zhCN = {
"decisions.status.pending": "待处理",
"decisions.status.decided": "已处理",
"decisions.status.expired": "已过期",
"decisions.status.approved": "已批准",
"decisions.status.rejected": "已拒绝",
"decisions.question": "需要你决定",
"decisions.diff_title": "改前 → 改后",
"decisions.col.field": "字段",
Expand Down
Loading
Loading