feat(home): 首页开场语改成一行问候,占位符改「想聊什么都可以」
开场语从「称呼行 + 追问句」收成 claude.ai 式的单行问候:时段池、 星期池、回访池按顺序拼成一个数组,按 variantSelection 确定性取一条; 名字为空时跳过带称呼的条目。副标题行与 .starter-salutation 下线, h1 降到 clamp(24px, 2.8vw, 30px) / line-height 1.25。追问改由输入框 占位符承担。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
dd281dd9c9
commit
ff0427cf08
+10
-4
@@ -471,10 +471,16 @@ The empty state is the composer. It used to be a bordered hero card at
|
||||
sitting on top of the input, so the primary action was the last thing on the
|
||||
page. Three parts now, in reading order:
|
||||
|
||||
- **Greeting** (`.starter-greeting-block`): a tertiary-ink salutation line and
|
||||
one question at `clamp(26px, 3.2vw, 34px)`, weight 500, centred. It lives in
|
||||
the conversation area with `align-content: end`, so it sits directly above the
|
||||
composer rather than floating in the middle of the row.
|
||||
- **Greeting** (`.starter-greeting-block`): one line, no subtitle — a single h1
|
||||
at `clamp(24px, 2.8vw, 30px)`, weight 500, `line-height: 1.25`, centred. The
|
||||
salutation line above the question is gone; so is the question, which is the
|
||||
composer placeholder's job now («想聊什么都可以»). The line is drawn per visit
|
||||
from three pools — daypart (morning / noon / afternoon / evening / late-night),
|
||||
weekday (Mon, Fri, Sat, Sun only), and returning (accounts that already have a
|
||||
session) — concatenated in that order and indexed by one random seed, with the
|
||||
`{名字}` entries dropped when no name is set. It lives in the conversation area
|
||||
with `align-content: end`, so it sits directly above the composer rather than
|
||||
floating in the middle of the row.
|
||||
- **Composer**: unchanged, and *in the same DOM position it occupies during a
|
||||
live session*. Nothing remounts when the first message lands, so an in-flight
|
||||
draft, focus, or queued card survives the transition. Its width is
|
||||
|
||||
@@ -8,6 +8,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
对用户称「你」。口语化,但不轻佻。不加「亲爱的」,不每轮感谢信任,不用 emoji。
|
||||
|
||||
首页开场语只有一行,不带追问句,不用星星、月亮、陪伴一类比喻。
|
||||
|
||||
## 五条原则
|
||||
|
||||
1. **先回应人,再展开方法。** 用户问婚姻,先用人话答方向,再进入度数和分盘。
|
||||
@@ -68,6 +70,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
| 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 |
|
||||
| (校正会话上普通输入框仍可打字) | 正在打开生时校正… | 校正会话上普通输入框只有禁用态。打开失败时按钮写「重新打开生时校正」。 |
|
||||
| 9月14日 · 生时校正 / 9月14日 · 生时校正 02:14 | 生时校正 · 9月14日 | 新建校正/今日节奏标题类别在前、日期在后。同日多条不再加墙钟时刻,靠副标题 `M月D日 HH:MM` 区分。旧标题不批量改。 |
|
||||
| 这么晚还醒着,{名字}。直接说说最在意的问题吧。 | 还没睡呀,{名字} | 开场只一行、不追问、不写场景;提问交给输入框占位符 |
|
||||
|
||||
## 服务端探针 → Agent 题干
|
||||
|
||||
|
||||
@@ -721,7 +721,8 @@ export default function Home() {
|
||||
);
|
||||
const onboardingFormActive = !profileComplete && onboardingStep !== "name";
|
||||
const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : "";
|
||||
const starterGreeting = createStartGreetingParts(profile.name, new Date(), starterGreetingSelection);
|
||||
const starterHasPriorSessions = sessions.some((session) => session.id !== activeSession?.id);
|
||||
const starterGreeting = createStartGreetingParts(profile.name, new Date(), starterGreetingSelection, starterHasPriorSessions);
|
||||
const conversationAnchor = useConversationScrollAnchor(
|
||||
conversation,
|
||||
!rectificationSurfaceOpen && !starterHomeVisible,
|
||||
|
||||
@@ -2089,29 +2089,25 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
padding: var(--space-8) 0 var(--space-5);
|
||||
}
|
||||
|
||||
/* The greeting: one line of salutation, one question. It used to be an h1 at
|
||||
/* The greeting: one line, two to five words. It used to be an h1 at
|
||||
clamp(34px, 5vw, 52px) inside a bordered hero card, with two 132px product
|
||||
cards under it — together about 800px sitting on top of the composer. */
|
||||
cards under it — together about 800px sitting on top of the composer; then a
|
||||
salutation line above a question. The question moved into the composer
|
||||
placeholder, so there is no second line to size against. */
|
||||
.starter-greeting-block {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.starter-salutation {
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
|
||||
.starter-greeting-block h1 {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(26px, 3.2vw, 34px);
|
||||
font-size: clamp(24px, 2.8vw, 30px);
|
||||
font-weight: 500;
|
||||
letter-spacing: -.02em;
|
||||
line-height: 1.3;
|
||||
line-height: 1.25;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,14 +19,15 @@ import type { RectificationEntrySummary } from "@/lib/rectification-entry";
|
||||
*/
|
||||
|
||||
export type StarterGreetingProps = {
|
||||
readonly starterGreeting: { readonly salutation: string; readonly question: string };
|
||||
readonly starterGreeting: { readonly text: string };
|
||||
};
|
||||
|
||||
/* One line. The salutation/question pair it replaces asked the user's question
|
||||
for them; the composer placeholder asks now. */
|
||||
export function StarterGreeting({ starterGreeting }: StarterGreetingProps) {
|
||||
return (
|
||||
<section className="starter-greeting-block" aria-labelledby="starter-heading">
|
||||
<p className="starter-salutation">{starterGreeting.salutation}</p>
|
||||
<h1 id="starter-heading">{starterGreeting.question}</h1>
|
||||
<h1 id="starter-heading">{starterGreeting.text}</h1>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function ordinaryComposerPlaceholder(input: {
|
||||
return input.presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…";
|
||||
}
|
||||
if (input.creditsEmpty) return "余额不足,可在账户与点数中补充";
|
||||
return "例如:未来半年是否适合换工作?";
|
||||
return "想聊什么都可以";
|
||||
}
|
||||
|
||||
export function rectificationOrdinaryComposerReopen(input: {
|
||||
|
||||
@@ -1,72 +1,86 @@
|
||||
type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night";
|
||||
|
||||
type GreetingVariant = {
|
||||
readonly salutation: (name: string) => string;
|
||||
readonly question: string;
|
||||
/**
|
||||
* One line, two to five words, no follow-up question.
|
||||
*
|
||||
* The homepage opening used to be a salutation plus a question ("这么晚还醒着,
|
||||
* {名字}。直接说说最在意的问题吧。") — an office voice that answered for the user
|
||||
* before they had said anything. Asking is the composer placeholder's job now;
|
||||
* the greeting only greets. No metaphors (星 / 月 / 陪), no emoji, no period.
|
||||
*/
|
||||
const NAME_TOKEN = "{名字}";
|
||||
|
||||
const periodGreetings: Record<GreetingPeriod, readonly string[]> = {
|
||||
morning: ["早上好", "早上好,{名字}", "早呀,{名字}"],
|
||||
noon: ["中午好", "中午好,{名字}"],
|
||||
afternoon: ["下午好", "下午好,{名字}"],
|
||||
evening: ["晚上好", "晚上好,{名字}", "今天过得怎么样?"],
|
||||
"late-night": ["夜猫子,晚上好", "还没睡呀,{名字}", "晚安前再聊一会"],
|
||||
};
|
||||
|
||||
const greetingVariants: Record<GreetingPeriod, readonly GreetingVariant[]> = {
|
||||
morning: [
|
||||
{ salutation: (name) => `早上好,${name}。`, question: "今天最想先看什么?" },
|
||||
{ salutation: (name) => `${name},早安。`, question: "今天最该关注哪件事?" },
|
||||
{ salutation: (name) => `早上好,${name}。`, question: "想从哪个问题开始?" },
|
||||
],
|
||||
noon: [
|
||||
{ salutation: (name) => `中午好,${name}。`, question: "现在最想理清哪件事?" },
|
||||
{ salutation: (name) => `${name},中午好。`, question: "什么问题最需要方向?" },
|
||||
{ salutation: (name) => `午间好,${name}。`, question: "事业、关系或选择,想先聊哪个?" },
|
||||
],
|
||||
afternoon: [
|
||||
{ salutation: (name) => `${name},下午好。`, question: "现在最想推进哪件事?" },
|
||||
{ salutation: (name) => `下午好,${name}。`, question: "今天想先理清什么?" },
|
||||
{ salutation: (name) => `${name},下午好。`, question: "事业、关系或选择,想先聊哪个?" },
|
||||
],
|
||||
evening: [
|
||||
{ salutation: (name) => `晚上好,${name}。`, question: "今天最挂心的是哪件事?" },
|
||||
{ salutation: (name) => `${name},晚上好。`, question: "此刻最想聊哪件事?" },
|
||||
{ salutation: (name) => `晚上好,${name}。`, question: "把心里的问题告诉我吧。" },
|
||||
],
|
||||
"late-night": [
|
||||
{ salutation: (name) => `夜深了,${name}。`, question: "此刻最想问什么?" },
|
||||
{ salutation: (name) => `${name},还没休息吗?`, question: "想从哪件事说起?" },
|
||||
{ salutation: (name) => `这么晚还醒着,${name}。`, question: "直接说说最在意的问题吧。" },
|
||||
],
|
||||
/** Keyed by `Date#getDay()`. Days with nothing worth saying stay out of the pool. */
|
||||
const weekdayGreetings: Readonly<Record<number, readonly string[]>> = {
|
||||
0: ["周日,慢一点"],
|
||||
1: ["周一了,{名字}"],
|
||||
5: ["周五快乐"],
|
||||
6: ["周末愉快,{名字}"],
|
||||
};
|
||||
|
||||
/** Only reachable once the account has at least one session behind it. */
|
||||
const returningGreetings: readonly string[] = [
|
||||
"又见面了,{名字}",
|
||||
"欢迎回来",
|
||||
"嗨,{名字}",
|
||||
"最近还好吗,{名字}?",
|
||||
];
|
||||
|
||||
function greetingPeriod(hour: number): GreetingPeriod {
|
||||
if (hour >= 5 && hour < 11) return "morning";
|
||||
if (hour >= 11 && hour < 14) return "noon";
|
||||
if (hour >= 14 && hour < 18) return "afternoon";
|
||||
if (hour >= 18 && hour < 23) return "evening";
|
||||
return "late-night";
|
||||
}
|
||||
|
||||
/**
|
||||
* Every line this moment can produce, in pool order: daypart, weekday, returning.
|
||||
* A blank name drops the lines that would print an empty one; the daypart pool
|
||||
* always keeps a nameless line, so the result is never empty.
|
||||
*/
|
||||
function starterGreetingPool(name: string, now: Date, hasSessions: boolean): readonly string[] {
|
||||
const templates = [
|
||||
...periodGreetings[greetingPeriod(now.getHours())],
|
||||
...(weekdayGreetings[now.getDay()] ?? []),
|
||||
...(hasSessions ? returningGreetings : []),
|
||||
];
|
||||
return name ? templates : templates.filter((template) => !template.includes(NAME_TOKEN));
|
||||
}
|
||||
|
||||
export type StartGreetingParts = {
|
||||
readonly salutation: string;
|
||||
readonly question: string;
|
||||
readonly text: string;
|
||||
};
|
||||
|
||||
export function createStartGreetingParts(
|
||||
name: string,
|
||||
now = new Date(),
|
||||
variantSelection = Math.random(),
|
||||
hasSessions = false,
|
||||
): StartGreetingParts {
|
||||
const displayName = name.trim() || "你好";
|
||||
const hour = now.getHours();
|
||||
const period: GreetingPeriod = hour >= 5 && hour < 11
|
||||
? "morning"
|
||||
: hour >= 11 && hour < 14
|
||||
? "noon"
|
||||
: hour >= 14 && hour < 18
|
||||
? "afternoon"
|
||||
: hour >= 18 && hour < 23 ? "evening" : "late-night";
|
||||
const variants = greetingVariants[period];
|
||||
const index = Math.min(Math.max(Math.floor(variantSelection * variants.length), 0), variants.length - 1);
|
||||
const variant = variants[index]!;
|
||||
return { salutation: variant.salutation(displayName), question: variant.question };
|
||||
const displayName = name.trim();
|
||||
const pool = starterGreetingPool(displayName, now, hasSessions);
|
||||
const index = Math.min(Math.max(Math.floor(variantSelection * pool.length), 0), pool.length - 1);
|
||||
return { text: pool[index]!.replaceAll(NAME_TOKEN, displayName) };
|
||||
}
|
||||
|
||||
export function createStartGreeting(
|
||||
name: string,
|
||||
now = new Date(),
|
||||
variantSelection = Math.random(),
|
||||
hasSessions = false,
|
||||
): string {
|
||||
const parts = createStartGreetingParts(name, now, variantSelection);
|
||||
return `${parts.salutation}${parts.question}`;
|
||||
return createStartGreetingParts(name, now, variantSelection, hasSessions).text;
|
||||
}
|
||||
|
||||
export function createOnboardingFallbackGreeting(name: string): string {
|
||||
return `${name.trim()},从你此刻最关心的问题开始吧。`;
|
||||
return `欢迎,${name.trim()}`;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ test("a queued card renders above the live textarea", () => {
|
||||
inputRef: createRef<HTMLTextAreaElement>(),
|
||||
value: "还想再问一句",
|
||||
inputLabel: "输入你的问题",
|
||||
placeholder: "例如:未来半年是否适合换工作?",
|
||||
placeholder: "想聊什么都可以",
|
||||
maxLength: 500,
|
||||
inputDisabled: false,
|
||||
submitLabel: "发送",
|
||||
|
||||
@@ -386,7 +386,9 @@ test("the starter heading is drawn per visit and the entry pills carry no fine p
|
||||
const source = homeSurface;
|
||||
|
||||
// Given: the heading reads from a variant seeded once per mount and redrawn when the home is left.
|
||||
assert.match(source, /<h1 id="starter-heading">\{starterGreeting\.question\}<\/h1>/);
|
||||
// 原值 `{starterGreeting.question}` / 新值 `{starterGreeting.text}` / 原因:开场语改成单行问候,
|
||||
// 「招呼语 + 问句」两半的形状取消,heading 直接就是整行;「每次进首页重抽」这条意图没变。
|
||||
assert.match(source, /<h1 id="starter-heading">\{starterGreeting\.text\}<\/h1>/);
|
||||
assert.match(source, /useState\(\(\) => Math\.random\(\)\)/);
|
||||
assert.match(source, /starterGreetingUnseen\.current = true;\s*\n\s*setStarterGreetingSelection\(Math\.random\(\)\)/);
|
||||
|
||||
@@ -426,7 +428,10 @@ test("the starter greeting keeps the display stack and display weight", () => {
|
||||
assert.match(heading, /font-weight:\s*500\s*;/);
|
||||
assert.doesNotMatch(heading, /font-family:\s*var\(--font-body\)/);
|
||||
// 问候不该再是 52px 的落地页大标题,它现在只是输入框上面的一行。
|
||||
assert.match(heading, /font-size:\s*clamp\(26px,/);
|
||||
// 原值 `clamp(26px,` / 新值 `clamp(24px,` / 原因:开场语从「招呼语 + 问句」两行收成单行问候,
|
||||
// 不再需要 34px 上限那一档;上限同步降到 30px,断言仍然钉住「不是落地页大标题」这条意图。
|
||||
assert.match(heading, /font-size:\s*clamp\(24px, 2\.8vw, 30px\);/);
|
||||
assert.match(heading, /line-height:\s*1\.25;/);
|
||||
});
|
||||
|
||||
test("the starter home hides technical chart parameters", () => {
|
||||
|
||||
@@ -8,59 +8,171 @@ import {
|
||||
} 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,避免建议问题串号。
|
||||
// 新值: 只保留问候语随当前称呼变化。
|
||||
// 原因: 建议问题请求已删除,身份去重不再有消费点。
|
||||
const terminalGreeting = createStartGreeting("周宁", new Date(2026, 6, 19, 8), 0);
|
||||
//
|
||||
// 原值 `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, /林遥/);
|
||||
}
|
||||
assert.equal(fallbackGreeting, "周宁,从你此刻最关心的问题开始吧。");
|
||||
assert.equal(terminalGreeting, "早上好,周宁");
|
||||
assert.equal(fallbackGreeting, "欢迎,周宁");
|
||||
});
|
||||
|
||||
test("the starter hero splits one time-aware greeting instead of drawing from a separate pool", () => {
|
||||
// Given: the same daypart variant, asked for as parts and as one line.
|
||||
const noon = new Date(2026, 6, 19, 12);
|
||||
const parts = createStartGreetingParts("周宁", noon, 0);
|
||||
const whole = createStartGreeting("周宁", noon, 0);
|
||||
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);
|
||||
assert.equal(createStartGreetingParts("周宁", noon, 0).text, "中午好");
|
||||
assert.equal(createStartGreetingParts("周宁", noon, 0.5).text, "中午好,周宁");
|
||||
assert.equal(createStartGreeting("周宁", noon, 0.5), createStartGreetingParts("周宁", noon, 0.5).text);
|
||||
|
||||
// Then: the salutation carries the name and the question carries no name, and they recompose exactly.
|
||||
assert.equal(parts.salutation, "中午好,周宁。");
|
||||
assert.equal(parts.question, "现在最想理清哪件事?");
|
||||
assert.equal(`${parts.salutation}${parts.question}`, whole);
|
||||
assert.doesNotMatch(parts.question, /周宁/);
|
||||
|
||||
// And: the extremes stay inside the variant list rather than falling off the end.
|
||||
assert.equal(createStartGreetingParts("周宁", noon, 1).question, "事业、关系或选择,想先聊哪个?");
|
||||
assert.equal(createStartGreetingParts("周宁", noon, 0.999999).question, "事业、关系或选择,想先聊哪个?");
|
||||
|
||||
// And: every daypart is reachable, so the heading is not one fixed line in disguise.
|
||||
const questions = new Set([0, 8, 12, 16, 20, 23].flatMap((hour) =>
|
||||
[0, 1 / 3, 2 / 3].map((selection) =>
|
||||
createStartGreetingParts("周宁", new Date(2026, 6, 19, hour), selection).question)));
|
||||
assert.ok(questions.size >= 12);
|
||||
// 边界不越界:1 与 0.999999 都落在池内最后一条。
|
||||
assert.equal(createStartGreetingParts("周宁", noon, 1).text, "中午好,周宁");
|
||||
assert.equal(createStartGreetingParts("周宁", noon, 0.999999).text, "中午好,周宁");
|
||||
});
|
||||
|
||||
test("the starter hero stays at a salutation and a question", () => {
|
||||
test("late night has its own three lines and none of them asks a follow-up", () => {
|
||||
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, "还没睡呀,周宁");
|
||||
});
|
||||
|
||||
test("a blank name leaves only the lines that carry no name", () => {
|
||||
const lateNight = WEDNESDAY(2);
|
||||
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.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));
|
||||
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);
|
||||
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;
|
||||
|
||||
// Given: the hero heading is the question half of the time-aware greeting.
|
||||
// 原值 `className="starter-greeting"` / 新值 `className="starter-salutation"`
|
||||
// / 原因:`.starter-greeting` 这个类名此前既指招呼语那一行、又被读成整块问候区,
|
||||
// 拆成 `.starter-greeting-block`(容器)与 `.starter-salutation`(招呼语)后不再歧义。
|
||||
// 「两行:招呼语 + 问句」这个意图本身没变。
|
||||
assert.match(source, /<p className="starter-salutation">\{starterGreeting\.salutation\}<\/p>/);
|
||||
assert.match(source, /<h1 id="starter-heading">\{starterGreeting\.question\}<\/h1>/);
|
||||
//
|
||||
// 原值:断言 `<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 two lines above already carry.
|
||||
// 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\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user