feat: complete device onboarding and mobile binding workflows
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-09-12 08:57:29 +08:00
parent 7846f30d0b
commit 98fb2965d0
116 changed files with 15669 additions and 64 deletions
+57
View File
@@ -0,0 +1,57 @@
// Synthetic customer data for layout checks; all API traffic is intercepted.
export const id = (n) => `10000000-0000-0000-0000-${String(n).padStart(12, "0")}`;
const now = new Date().toISOString();
const tomorrow = new Date(Date.now() + 86400000).toISOString();
const capabilities = ["household.read", "household.manage", "member.manage", "subject.read", "subject.write", "consent.manage", "device.read", "device.manage", "conversation.read", "story.review", "privacy.read", "privacy.manage", "privacy.request", "recording.access.manage", "sharing.manage"];
export const households = ["林家的安心陪伴空间", "陈家的家庭空间", "外婆的家庭空间"].map((name, i) => ({ id: id(200 + i), tenant_id: id(1), name, status: "active", role: "owner", capabilities, version: 1, created_at: now, updated_at: now }));
export const currentUser = { identity: { issuer: "http://localhost:58180/realms/voice-local", subject: "mobile-fixture", display_name: "林家的家庭管理员", preferred_username: "mobile-fixture" }, accounts: [{ user_id: id(100), tenant_id: id(1), tenant_name: "家庭空间", policy_version: "2026-09", platform_role: "customer", account_kind: "customer", display_name: "林家的家庭管理员", household_memberships: households.map((h) => ({ household_id: h.id, household_name: h.name, role: h.role, capabilities })) }] };
export const subjects = ["林秀兰", "陈建国", "王素梅"].map((preferred_name, i) => ({ id: id(400 + i), household_id: id(200), preferred_name, language: "zh-CN", timezone: "Asia/Shanghai", profile_data: { background: "退休后住在杭州,喜欢和家人分享以前的工作与生活经历。", interests: ["养花", "旅行", "听戏曲"], relationship: "妈妈", birth_year: 1948, city: "杭州" }, conversation_preferences: { conversation_goal: "轻松聊聊当天的生活与年轻时的工作", pace: "slow", preferred_topics: ["家乡", "老朋友"], avoided_topics: ["疾病"] }, status: "active", version: 1, created_at: now, updated_at: now }));
export const devices = subjects.map((s, i) => ({ id: id(500 + i), binding_id: id(510 + i), household_id: id(200), subject_id: s.id, subject_name: s.preferred_name, model: i ? "安心陪伴音箱" : "Companion-Speaker-Pro-2026-Long-Model-Name", status: "registered", firmware_version: "1.2.0", desired_revision: 1, reported_revision: 1, last_reported_at: now, bound_at: now, registered_at: now, version: 1 }));
export const members = ["林家的家庭管理员", "陈建国的家人"].map((display_name, i) => ({ id: id(300 + i), user_id: id(100 + i), display_name, role: i ? "editor" : "owner", status: "active", joined_at: now, version: 1, is_owner: !i }));
export const conversations = Array.from({ length: 7 }, (_, i) => ({ id: id(600 + i), subject_id: id(400), device_id: id(500), status: "COMPLETED", runtime_type: "legacy_voicechat", engine_mode: "s2s_hybrid", retention_mode: "transcript_and_audio", audio_retention_enabled: true, started_at: new Date(Date.now() - i * 86400000 - 1200000).toISOString(), ended_at: new Date(Date.now() - i * 86400000).toISOString(), duration_ms: 1200000, created_at: now, updated_at: now }));
export const stories = ["年轻时第一次独自乘火车去杭州的故事", "和老朋友一起种花的春天", "留给家人的童年回忆"].map((title, i) => ({ id: id(700 + i), subject_id: id(400), conversation_id: id(600), title, content: "那时候的火车站很热闹,第一次独自出门的时候,家人送我到了站台。后来结识了新朋友,我们一起工作、生活,留下许多珍贵的回忆。".repeat(5), status: "CANDIDATE", sensitivity: "normal", confidence: 0.92, generation_version: 1, revision_no: 1, author_type: "model", version: 1, created_at: now, updated_at: now, sources: [], facts: [{ id: id(750 + i), fact_type: "life_event", content: "年轻时在杭州工作", sensitivity: "normal", source_turn_ids: [id(650)], status: "CANDIDATE", version: 1 }] }));
const memories = [{ id: id(800), subject_id: id(400), content: "喜欢养花,和家人在杭州生活,每周都会和老朋友见面。", source_refs: [], status: "ACTIVE", sensitivity: "normal", version_no: 1, version: 1, confirmed_by: id(100), confirmed_at: now, index_status: "ACTIVE", provider_index_status: "DISABLED", created_at: now, updated_at: now }];
const consents = ["transcript_storage", "raw_audio_storage", "story_extraction", "personalized_memory", "family_sharing"].map((type, i) => ({ id: id(900 + i), subject_id: id(400), type, scope: {}, effective: true, version: 1, granted_at: now, policy_version: "2026-09", notice_digest: "synthetic-fixture", actor_user_id: id(100) }));
export async function installCustomerFixture(context, { empty = false, failure = false, authenticated = true } = {}) {
const unexpected = [];
if (authenticated) await context.addInitScript(({ currentUser }) => {
sessionStorage.setItem("oidc.user:http://localhost:58180/realms/voice-local:voice-platform-web", JSON.stringify({ access_token: "synthetic-mobile-layout-token", token_type: "Bearer", profile: { sub: "mobile-fixture", ...currentUser.identity }, expires_at: Math.floor(Date.now() / 1000) + 3600 }));
}, { currentUser });
const list = (items) => ({ items: empty ? [] : items });
await context.route("**/api/v1/**", async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname.replace("/api/v1", "");
if (request.method() !== "GET") { unexpected.push(`${request.method()} ${path}`); return route.abort(); }
if (path === "/events") return route.fulfill({ contentType: "text/event-stream", body: ": mobile layout fixture\n\n" });
if (failure && path === "/households") return route.fulfill({ status: 503, json: { error: { code: "unavailable", message: "家庭资料暂时无法读取,请稍后重试。" } } });
let data;
if (path === "/me") data = currentUser;
else if (path === "/households") data = list(households);
else if (path === "/devices") data = list(devices);
else if (/\/households\/[^/]+\/subjects$/.test(path)) data = list(subjects);
else if (/\/members$/.test(path)) data = list(members);
else if (/\/invitations$/.test(path)) data = list([{ id: id(350), household_id: id(200), invitee_hint: "邀请一起照顾长者的家人", role: "viewer", status: "pending", expires_at: tomorrow, created_at: now, version: 1 }]);
else if (/\/facts$/.test(path)) data = list([{ id: id(950), subject_id: id(400), fact_type: "background", content: "年轻时在杭州工作,喜欢种花和听戏曲。", source_type: "family_entry", confirmation_status: "pending", sensitivity: "normal", version: 1, created_at: now, updated_at: now }]);
else if (/\/consents$/.test(path)) data = list(consents);
else if (/\/conversation-readiness$/.test(path)) data = { subject_id: id(400), ready: true, evaluated_at: now, checks: [{ code: "PROFILE_BACKGROUND", status: "ready", blocking: false, title: "陪伴资料已完善", detail: "已填写背景与交流偏好" }], devices, start_time_checks: [] };
else if (/\/subjects\/[^/]+\/conversations$/.test(path)) data = list(conversations);
else if (/\/conversations\/[^/]+$/.test(path)) data = { ...(conversations.find((c) => path.endsWith(c.id)) || conversations[0]), household_id: id(200), snapshots: { consent_snapshot_id: id(901), consent_snapshot_version: 1, context_snapshot_id: id(902), profile_version: 1, prompt_bundle_version: "mobile-qa", context_preparation_status: "READY" }, history: [{ from_status: "ACTIVE", to_status: "COMPLETED", reason: "user_hangup", source: "device", occurred_at: now }] };
else if (/\/turns$/.test(path)) data = list(Array.from({ length: 6 }, (_, i) => ({ id: id(650 + i), sequence_no: i + 1, speaker: i % 2 ? "agent" : "subject", start_at: now, end_at: now, final_text: i % 2 ? "能和我分享一下当时的经历吗?" : "那时候我第一次独自乘火车出门,到杭州后认识了好多新朋友。".repeat(3), interrupted: false, source_type: "device" })));
else if (/\/summaries$/.test(path)) data = list([{ id: id(660), type: "conversation_summary", content: "长者聊起在杭州工作与养花的经历,心情轻松。关键词:杭州、工作、养花。", source_turn_ids: [id(650)], model_ref: "mobile-layout-fixture", prompt_version: "v1", generation_version: 1, status: "READY" }, { id: id(661), type: "open_topic", content: "下次可以继续聊聊老朋友与家乡的春天。", source_turn_ids: [id(650)], model_ref: "mobile-layout-fixture", prompt_version: "v1", generation_version: 1, status: "READY" }]);
else if (/\/usage$/.test(path)) data = { conversation_id: id(600), currency: "CNY", cost_micros: 120000, lines: [] };
else if (/\/media$/.test(path)) data = { conversation_id: id(600), segments: ["near", "far"].map((track, i) => ({ id: id(680 + i), track, sequence_no: 1, duration_ms: 1200000, expected_size: 38400000, status: "VERIFIED" })), can_play: true, can_download: true };
else if (/\/subjects\/[^/]+\/stories$/.test(path)) data = list(stories);
else if (/\/stories\/[^/]+$/.test(path)) data = stories.find((s) => path.endsWith(s.id));
else if (/\/memories$/.test(path)) data = list(memories);
else if (/\/(privacy-requests|exports|recording-access-grants|content-models)$/.test(path)) data = list([]);
else if (path === "/sharing-grants") data = list([{ id: id(980), subject_id: id(400), resource_type: "STORY", resource_id: id(700), permission: "VIEW_TEXT", purpose: "与家人分享珍贵的童年回忆", grantee_user_id: id(101), login_required: true, status: "ACTIVE", expires_at: tomorrow, created_at: now, version: 1 }]);
else if (/\/presence$/.test(path)) data = { device_id: id(500), online: true, status: "online", last_seen_at: now, last_heartbeat_at: now };
else if (/\/twin$/.test(path)) data = { device_id: id(500), desired_revision: 1, reported_revision: 1, desired_config: { speaker_volume: 60 }, reported_config: { speaker_volume: 60 }, updated_at: now };
else if (/\/usage-overview$/.test(path)) data = { from: url.searchParams.get("from"), to: url.searchParams.get("to"), timezone: "Asia/Shanghai", scope: { household_name: households[0].name }, total_duration_ms: 8400000, today_duration_ms: 1200000, average_duration_ms: 1200000, conversation_count: 7, active_days: 7, current_streak_days: 7, longest_streak_days: 7, cost_micros: 1200000, currency: "CNY", cost_available: true, daily: empty ? [] : conversations.map((c) => ({ date: c.started_at.slice(0, 10), duration_ms: c.duration_ms, conversation_count: 1, cost_micros: 120000 })), devices: empty ? [] : devices.map((d) => ({ id: d.id, name: d.model, conversation_count: 7, duration_ms: 8400000, cost_micros: 1200000 })), recent_conversations: empty ? [] : conversations.map((c) => ({ ...c, subject_name: subjects[0].preferred_name, device_name: devices[0].model, cost_micros: 120000 })) };
else { unexpected.push(`${request.method()} ${path}`); return route.fulfill({ status: 404, json: { error: { message: `Missing layout fixture: ${path}` } } }); }
return route.fulfill({ json: { request_id: "mobile-layout-fixture", data } });
});
return unexpected;
}