Files
Jyotisha/frontend/tests/onboarding-presentation.test.ts
T
Jesse_ChenandClaude Opus 5.5 dc45fde613
Independent Staging Quality Gate / validate (push) Successful in 12m47s
Independent Staging Quality Gate / publish (push) Successful in 3m47s
feat(home): warm older-sister greeting voice; daily line says why, plainly
Late night no longer remarks on being awake (产品 2026-10-01); every daypart,
weekday and returning line reworded in the same caring voice. Daily line:
each house sentence names where the Moon is in plain words, then one concrete
sentence; no stacked hedges. Sign-fallback moods rewritten the same way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-01 19:34:16 +08:00

192 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import {
createOnboardingFallbackGreeting,
createStartGreeting,
createStartGreetingParts,
} from "../src/lib/starter-greeting.ts";
import { homeSurface } from "./home-surface.ts";
// 2026-07-19 是周日,20 周一,22 周三(星期池为空),24 周五,25 周六。
const SUNDAY = (hour: number) => new Date(2026, 6, 19, hour);
const MONDAY = (hour: number) => new Date(2026, 6, 20, hour);
const WEDNESDAY = (hour: number) => new Date(2026, 6, 22, hour);
const FRIDAY = (hour: number) => new Date(2026, 6, 24, hour);
test("terminal and fallback greetings follow the current profile name", () => {
// 原值: 同时锁 onboardingRequestIdentity / isCurrentOnboardingRequest,避免建议问题串号。
// 新值: 只保留问候语随当前称呼变化。
// 原因: 建议问题请求已删除,身份去重不再有消费点。
//
// 原值 `createStartGreeting("周宁", …, 0)` + `"周宁,从你此刻最关心的问题开始吧。"`
// / 新值 `createStartGreeting("周宁", …, 0.25)` + `"欢迎,周宁"`
// / 原因:时段池现在第一条是不带称呼的「早上好」,要取带称呼的条目得换下标;
// 兜底问候按产品口径改成单行「欢迎,{名字}」,不再追问。断言强度没变:
// 两条都仍然必须带当前称呼、不得带旧称呼。
const terminalGreeting = createStartGreeting("周宁", SUNDAY(8), 0.25);
const fallbackGreeting = createOnboardingFallbackGreeting("周宁");
for (const greeting of [terminalGreeting, fallbackGreeting]) {
assert.match(greeting, /周宁/);
assert.doesNotMatch(greeting, /林遥/);
}
// 原值 "早上好,周宁" / 新值 "早安,周宁" / 原因:开场语改知心姐姐口吻(产品 2026-10-01)。
assert.equal(terminalGreeting, "早安,周宁");
assert.equal(fallbackGreeting, "欢迎,周宁");
});
test("the starter greeting is one line drawn from daypart, weekday and returning pools", () => {
// 原值:`parts.salutation` = "中午好,周宁。"、`parts.question` = "现在最想理清哪件事?",
// 并断言两半拼起来等于整行、每个时段至少 12 条不同问句。
// 新值:`parts.text` 就是整行问候;断言时段池按下标取到「中午好」/「中午好,周宁」。
// 原因:产品把开场语改成 claude.ai 式的单行问候,追问句移到输入框占位符,
// 「招呼语 + 问句」两半这个形状本身被取消,问句池不再存在。
const noon = WEDNESDAY(12);
// 原值 "中午好" / "中午好,周宁" / 新值 "中午好,记得吃饭" / "午安,周宁"
// / 原因:开场语改知心姐姐口吻(产品 2026-10-01);下标与池长不变。
assert.equal(createStartGreetingParts("周宁", noon, 0).text, "中午好,记得吃饭");
assert.equal(createStartGreetingParts("周宁", noon, 0.5).text, "午安,周宁");
assert.equal(createStartGreeting("周宁", noon, 0.5), createStartGreetingParts("周宁", noon, 0.5).text);
// 边界不越界:1 与 0.999999 都落在池内最后一条。
assert.equal(createStartGreetingParts("周宁", noon, 1).text, "午安,周宁");
assert.equal(createStartGreetingParts("周宁", noon, 0.999999).text, "午安,周宁");
});
test("late night has its own three lines and none of them remarks on being awake", () => {
// 原值 "夜猫子,晚上好" / "还没睡呀,周宁" / "晚安前再聊一会"
// / 新值 "这会儿很安静" / "夜里好,周宁" / "慢慢说,周宁"
// / 原因:产品 2026-10-01「夜猫子、还不睡,用户会觉得你管别人睡不睡」;断言加严:深夜池任何一条都不得提睡觉。
const lateNight = WEDNESDAY(2);
assert.equal(createStartGreetingParts("周宁", lateNight, 0).text, "这会儿很安静");
assert.equal(createStartGreetingParts("周宁", lateNight, 1 / 3).text, "夜里好,周宁");
assert.equal(createStartGreetingParts("周宁", lateNight, 0.7).text, "慢慢说,周宁");
// 23 点与 4 点同属深夜。
assert.equal(createStartGreetingParts("周宁", WEDNESDAY(23), 1 / 3).text, "夜里好,周宁");
assert.equal(createStartGreetingParts("周宁", WEDNESDAY(4), 1 / 3).text, "夜里好,周宁");
for (let step = 0; step < 100; step += 1) {
assert.doesNotMatch(createStartGreetingParts("周宁", lateNight, step / 100).text, /睡|夜猫子|晚安/);
}
});
test("a blank name leaves only the lines that carry no name", () => {
const lateNight = WEDNESDAY(2);
// 原值 "夜猫子,晚上好" / "晚安前再聊一会" / 新值 "这会儿很安静"(深夜池唯一不带称呼的一条)
// / 原因:深夜池换句(产品 2026-10-01)。
assert.equal(createStartGreetingParts("", lateNight, 0).text, "这会儿很安静");
assert.equal(createStartGreetingParts(" ", lateNight, 0.9).text, "这会儿很安静");
// 原值:断言名字为空时不出现「周一了」/ 新值:只断言带称呼的回访条目不可达
// / 原因:周一条目改成不带称呼的「周一了,别太累」,名字为空也该出现;带称呼条目不可达的断言保留。
for (let step = 0; step < 100; step += 1) {
for (const hasSessions of [false, true]) {
const text = createStartGreetingParts("", MONDAY(9), step / 100, hasSessions).text;
assert.doesNotMatch(text, /又见面了|你来啦|最近还好吗/);
assert.ok(text.length > 0);
assert.doesNotMatch(text, /\{名字\}/);
assert.doesNotMatch(text, /,$|^,/);
}
}
});
test("the weekday pool only opens on its own day", () => {
const friday = FRIDAY(9);
const fridayLines = new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", friday, step / 100).text));
// 原值 "周五快乐" / "周一了,周宁" / 新值 "周五了,松口气" / "周一了,别太累" / 原因:知心姐姐口吻(产品 2026-10-01)。
assert.ok(fridayLines.has("周五了,松口气"));
const wednesdayLines = new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", WEDNESDAY(9), step / 100).text));
assert.ok(!wednesdayLines.has("周五了,松口气"));
assert.equal(wednesdayLines.size, 3);
assert.ok(new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", MONDAY(9), step / 100).text)).has("周一了,别太累"));
assert.ok(new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", SUNDAY(9), step / 100).text)).has("周日,慢一点"));
});
test("the returning pool needs at least one session behind the account", () => {
const evening = WEDNESDAY(20);
// 原值 "嗨,周宁" / 新值 "你来啦,周宁" / 原因:知心姐姐口吻(产品 2026-10-01);池长不变。
const returning = ["又见面了,周宁", "你来啦,周宁", "欢迎回来", "最近还好吗,周宁?"];
const firstVisit = new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", evening, step / 100, false).text));
for (const line of returning) assert.ok(!firstVisit.has(line), `first visit must not reach ${line}`);
assert.equal(firstVisit.size, 3);
const returnVisit = new Set(Array.from({ length: 100 }, (_, step) =>
createStartGreetingParts("周宁", evening, step / 100, true).text));
for (const line of returning) assert.ok(returnVisit.has(line), `return visit must reach ${line}`);
assert.equal(returnVisit.size, 7);
});
test("the same inputs always produce the same line", () => {
for (const hour of [2, 8, 12, 16, 20]) {
for (const hasSessions of [false, true]) {
for (const step of [0, 17, 42, 99]) {
const args = [hour, step / 100, hasSessions] as const;
const first = createStartGreeting("周宁", WEDNESDAY(args[0]), args[1], args[2]);
const second = createStartGreeting("周宁", WEDNESDAY(args[0]), args[1], args[2]);
assert.equal(first, second);
}
}
}
});
test("no greeting line uses a metaphor or trails off into a sentence", () => {
for (let day = 19; day <= 25; day += 1) {
for (let hour = 0; hour < 24; hour += 1) {
for (const name of ["周宁", ""]) {
for (const hasSessions of [false, true]) {
for (let step = 0; step < 100; step += 1) {
const text = createStartGreetingParts(
name,
new Date(2026, 6, day, hour),
step / 100,
hasSessions,
).text;
assert.doesNotMatch(text, /[星月陪]/, `metaphor in ${text}`);
assert.doesNotMatch(text, /。$/, `sentence period in ${text}`);
assert.ok(text.length > 0);
assert.ok(text.length <= 12, `${text} is longer than one short line`);
}
}
}
}
}
});
test("the starter hero is one heading and nothing else", () => {
const source = homeSurface;
// 原值 `className="starter-greeting"` / 新值 `className="starter-salutation"`
// / 原因:`.starter-greeting` 这个类名此前既指招呼语那一行、又被读成整块问候区,
// 拆成 `.starter-greeting-block`(容器)与 `.starter-salutation`(招呼语)后不再歧义。
//
// 原值:断言 `<p className="starter-salutation">` 与 `<h1>{starterGreeting.question}</h1>` 同时存在
// / 新值:断言只剩 `<h1 id="starter-heading">{starterGreeting.text}</h1>`,且 `starter-salutation` 在源码与样式里都消失
// / 原因:开场语改成单行问候,副标题那一行连同它的样式一起下线;断言从「两行」改成「一行且不得有第二行」,强度不降反升。
assert.match(source, /<h1 id="starter-heading">\{starterGreeting\.text\}<\/h1>/);
assert.doesNotMatch(source, /starter-salutation/);
assert.doesNotMatch(source, /starterGreeting\.(salutation|question)/);
// Then: no third line repeats the welcome the heading already carries.
assert.doesNotMatch(source, /starter-hero-note/);
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
assert.doesNotMatch(styles, /starter-hero-note/);
assert.doesNotMatch(styles, /starter-salutation/);
// And: the separate static heading pool is gone for good.
assert.doesNotMatch(source, /starterPrompt|createStarterPrompt|greetingForHour/);
assert.doesNotMatch(source, /greeting: createStartGreeting\(/);
});
test("the greeting knows whether the account already has sessions", () => {
// hasSessions 必须来自已有的会话列表变量,不得新开一份状态。
assert.match(homeSurface, /const starterHasPriorSessions = sessions\.some\(/);
assert.match(homeSurface, /createStartGreetingParts\(profile\.name, new Date\(\), starterGreetingSelection, starterHasPriorSessions\)/);
});