diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md index b21be2cc..23aa8799 100644 --- a/.superpowers/sdd/task-4-report.md +++ b/.superpowers/sdd/task-4-report.md @@ -40,12 +40,21 @@ Final-fix RED artifact: `.omo/evidence/task-4-final-red.log`. - Python: 4/4 passed, including the new same-month/day-precision regression, confirming that the production behavior existed but previously lacked durable coverage. +Standards-axis RED artifact: `.omo/evidence/task-4-axis-red.log`. + +- TypeScript: 19 tests, 2 expected failures. Server prompts of 121 and 240 characters were + accepted by binder and service instead of failing before ID allocation and commit. +- Python: 4/4 passed after replacing localized precision glyph assertions with numeric-boundary + structure and normalized uniqueness checks. + ## Implementation - `birth-time-dynamic-question-copy.ts` now contains only server-copy structural validation, NFKC/whitespace label normalization, the note-free opportunity-selection projection, and deterministic server-copy fingerprinting. The former note blacklist and substring - grounding logic were removed. + grounding logic were removed. Shared constants cap server questions at 120 characters and + labels at 80 across the API adapter, public schema, internal model, persisted schema, and + binding guard. - `birth-time-dynamic-question-validator.ts` accepts only strict selection objects. Binding resolves the selected server opportunity, validates the prompt and normalized uniqueness across every primary and reserved visible label, validates every matching private @@ -57,7 +66,9 @@ Final-fix RED artifact: `.omo/evidence/task-4-final-red.log`. - `dynamic_rectification_copy.py` owns localized contexts and the least detailed year/month/day range representation needed to distinguish visible windows. Cross-year ranges stay concise; same-year or same-month collisions gain month or day precision. - `dynamic_rectification_opportunities.py` is again below the 250-pure-LOC boundary. + Its precision discriminator is the exhaustive `Literal["year", "month", "day"]` domain; + unknown precision cannot silently fall through. `dynamic_rectification_opportunities.py` + remains below the 250-pure-LOC boundary. - The Mastra contract describes selection only and forbids prompt/options/labels/partition fields in Agent output. @@ -70,24 +81,29 @@ through the Task 4 service. Task 5 persistence was not changed. | Gate | Result | Artifact | | --- | --- | --- | -| Final-fix RED | expected 2 TS failures; Python 4/4 | `.omo/evidence/task-4-final-red.log` | -| Focused dynamic/guide TypeScript | 38/38 pass | `.omo/evidence/task-4-final-focused-ts.log` | -| Focused Task 2 Python | 26/26 pass | `.omo/evidence/task-4-final-focused-python.log` | -| Legacy Python rectification | 22/22 pass | `.omo/evidence/task-4-final-legacy-python.log` | -| All birth-time TypeScript | 225/225 pass | `.omo/evidence/task-4-final-birth-time.log` | -| Full frontend | 300/300 pass | `.omo/evidence/task-4-final-frontend-full.log` | -| Cumulative changed TypeScript ESLint | pass, zero diagnostics | `.omo/evidence/task-4-final-eslint.log` | -| Cumulative changed Python Ruff | pass | `.omo/evidence/task-4-final-ruff.log` | -| Diff check and all changed TS/Python LOC | pass; every audited file <=250 | `.omo/evidence/task-4-final-quality.log` | -| Full TypeScript check | only known unrelated `profile-persistence.test.ts:7` TS1501 | `.omo/evidence/task-4-final-tsc.log` | -| Fresh final-fix review | CLEAR / APPROVE; no blockers or WATCH items | `.omo/evidence/task-4-final-fix-code-review.md` | +| Standards-axis RED | expected 2 TS failures; Python 4/4 | `.omo/evidence/task-4-axis-red.log` | +| Focused dynamic/guide TypeScript | 40/40 pass | `.omo/evidence/task-4-axis-focused-ts.log` | +| Public dynamic-choice schema TypeScript | 7/7 pass | `.omo/evidence/task-4-axis-public-schema-ts.log` | +| Dynamic adapter boundary TypeScript | 8/8 pass | `.omo/evidence/task-4-axis-adapter-ts.log` | +| Focused Task 2 Python | 26/26 pass | `.omo/evidence/task-4-axis-focused-python.log` | +| Legacy Python rectification | 22/22 pass | `.omo/evidence/task-4-axis-legacy-python.log` | +| All birth-time TypeScript | 229/229 pass | `.omo/evidence/task-4-axis-birth-time.log` | +| Full frontend | 304/304 pass | `.omo/evidence/task-4-axis-frontend-full.log` | +| Cumulative changed TypeScript ESLint | pass, zero diagnostics | `.omo/evidence/task-4-axis-eslint.log` | +| Cumulative changed Python Ruff | pass | `.omo/evidence/task-4-axis-ruff.log` | +| Diff check and all changed TS/Python LOC | pass; every audited file <=250 | `.omo/evidence/task-4-axis-quality.log` | +| Full TypeScript check | only known unrelated `profile-persistence.test.ts:7` TS1501 | `.omo/evidence/task-4-axis-tsc.log` | +| Fresh standards-axis fix review | CLEAR / APPROVE; no blockers | `.omo/evidence/task-4-axis-fix-code-review.md` | The TypeScript command remains non-zero solely because the pre-existing profile-persistence test uses a regular-expression flag newer than the configured target. No Task 4 file reports a type error. The earlier `.omo/evidence/task-4-selection-boundary-code-review.md` `CLEAR` is explicitly superseded by `.omo/evidence/task-4-final-review.md`; it is not cited as current acceptance. -The final reviewer independently rechecked reserved-label normalization, zero allocation and -commit, the packet-only prompt API, typed Python range-copy boundary, same-month day precision, -prose-pin removal, and cumulative LOC. Both required programming language perspectives and the -remove-slops perspective returned `CLEAR / APPROVE` with no remaining WATCH item. +The earlier `.omo/evidence/task-4-final-fix-code-review.md` `CLEAR` is explicitly superseded by +the standards-axis review and is not cited as current acceptance. The new tests contain no +localized month/day or domain-word assertions; precision is verified through distinct normalized +labels and the number of numeric range-boundary tokens. +The fresh reviewer also verified the shared 120/80 boundary through the public schema, API +adapter, internal and persisted schemas, and binding guard. Both programming language +perspectives and the remove-slops perspective returned `CLEAR / APPROVE`. diff --git a/frontend/src/lib/birth-time-dynamic-choice-internal.ts b/frontend/src/lib/birth-time-dynamic-choice-internal.ts index 4e5612b9..0bcf8129 100644 --- a/frontend/src/lib/birth-time-dynamic-choice-internal.ts +++ b/frontend/src/lib/birth-time-dynamic-choice-internal.ts @@ -1,5 +1,9 @@ import { z } from "zod"; import { candidateResultSchema } from "./birth-time-evidence.ts"; +import { + DYNAMIC_QUESTION_LABEL_MAX_LENGTH, + DYNAMIC_QUESTION_PROMPT_MAX_LENGTH, +} from "./birth-time-dynamic-question-limits.ts"; import { publicChoiceKindSchema, publicDynamicChoiceQuestionSchema, @@ -111,7 +115,7 @@ export type DynamicControlState = { const evidencePartitionBaseSchema = z.object({ partitionId: z.string().trim().min(1), descriptor: z.string().trim().min(1), - fallbackLabel: z.string().trim().min(1).max(80), + fallbackLabel: z.string().trim().min(1).max(DYNAMIC_QUESTION_LABEL_MAX_LENGTH), }).strict(); export const evidencePartitionSchema = evidencePartitionBaseSchema.readonly(); @@ -126,7 +130,7 @@ export const questionOpportunitySchema = z.object({ neutralContext: z.string().trim().min(1), estimatedInformationGain: z.number().finite().nonnegative(), candidatePartitionFingerprint: z.string().trim().min(1), - fallbackPrompt: z.string().trim().min(1).max(240), + fallbackPrompt: z.string().trim().min(1).max(DYNAMIC_QUESTION_PROMPT_MAX_LENGTH), partitions: z.array(evidencePartitionSchema).min(2).max(4).readonly(), }).strict().readonly(); @@ -148,7 +152,7 @@ export const candidateDifferenceBuildSchema = z.object({ const persistedPrimaryChoiceSchema = z.object({ optionId: z.string().trim().min(1), - label: z.string().trim().min(1).max(80), + label: z.string().trim().min(1).max(DYNAMIC_QUESTION_LABEL_MAX_LENGTH), kind: z.literal("primary"), partitionId: z.string().trim().min(1), candidateScores: finiteScoresSchema, @@ -156,7 +160,7 @@ const persistedPrimaryChoiceSchema = z.object({ const persistedSpecialChoiceSchema = (kind: "unknown" | "unmatched") => z.object({ optionId: z.string().trim().min(1), - label: z.string().trim().min(1).max(80), + label: z.string().trim().min(1).max(DYNAMIC_QUESTION_LABEL_MAX_LENGTH), kind: z.literal(kind), partitionId: z.null(), candidateScores: z.null(), @@ -171,7 +175,7 @@ export const persistedDynamicChoiceQuestionSchema = z.object({ source: z.enum(["agent", "fallback"]), questionFingerprint: z.string().trim().min(1), candidatePartitionFingerprint: z.string().trim().min(1), - prompt: z.string().trim().min(1).max(240), + prompt: z.string().trim().min(1).max(DYNAMIC_QUESTION_PROMPT_MAX_LENGTH), options: z.array(z.union([ persistedPrimaryChoiceSchema, persistedSpecialChoiceSchema("unknown"), diff --git a/frontend/src/lib/birth-time-dynamic-choice.ts b/frontend/src/lib/birth-time-dynamic-choice.ts index b080f867..fc2b63a8 100644 --- a/frontend/src/lib/birth-time-dynamic-choice.ts +++ b/frontend/src/lib/birth-time-dynamic-choice.ts @@ -1,4 +1,8 @@ import { z } from "zod"; +import { + DYNAMIC_QUESTION_LABEL_MAX_LENGTH, + DYNAMIC_QUESTION_PROMPT_MAX_LENGTH, +} from "./birth-time-dynamic-question-limits.ts"; const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/); @@ -18,7 +22,7 @@ export type TimeRange = { export const publicDynamicChoiceOptionSchema = z.object({ optionId: z.string().trim().min(1), - label: z.string().trim().min(1).max(80), + label: z.string().trim().min(1).max(DYNAMIC_QUESTION_LABEL_MAX_LENGTH), kind: publicChoiceKindSchema, }).strict().readonly(); @@ -55,7 +59,7 @@ function validateOptionSet( export const publicDynamicChoiceQuestionSchema = z.object({ questionId: z.string().trim().min(1), - prompt: z.string().trim().min(1).max(240), + prompt: z.string().trim().min(1).max(DYNAMIC_QUESTION_PROMPT_MAX_LENGTH), options: z.array(publicDynamicChoiceOptionSchema).readonly(), }).strict().superRefine(validateOptionSet).readonly(); diff --git a/frontend/src/lib/birth-time-dynamic-question-copy.ts b/frontend/src/lib/birth-time-dynamic-question-copy.ts index 4f69ff67..c4411452 100644 --- a/frontend/src/lib/birth-time-dynamic-question-copy.ts +++ b/frontend/src/lib/birth-time-dynamic-question-copy.ts @@ -1,11 +1,17 @@ import { createHash } from "node:crypto"; +import { + DYNAMIC_QUESTION_LABEL_MAX_LENGTH, + DYNAMIC_QUESTION_PROMPT_MAX_LENGTH, +} from "./birth-time-dynamic-question-limits.ts"; import type { CandidateDifferencePacket } from "./birth-time-dynamic-choice-internal.ts"; const clockTimePattern = /(?:^|[^\d])(?:[01]?\d|2[0-3])\s*[::]\s*[0-5]\d(?:$|[^\d])/; export function dynamicServerCopyIsSafe(value: string, question: boolean): boolean { const normalized = value.normalize("NFKC").trim(); - if (normalized.length > (question ? 240 : 80)) return false; + if (normalized.length > ( + question ? DYNAMIC_QUESTION_PROMPT_MAX_LENGTH : DYNAMIC_QUESTION_LABEL_MAX_LENGTH + )) return false; if (!/[\u3400-\u9fff]/u.test(normalized) || /[A-Za-z]/.test(normalized)) return false; if (clockTimePattern.test(normalized)) return false; return !question || (normalized.match(/[??]/g) ?? []).length === 1; diff --git a/frontend/src/lib/birth-time-dynamic-question-limits.ts b/frontend/src/lib/birth-time-dynamic-question-limits.ts new file mode 100644 index 00000000..7b7fd0bf --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-question-limits.ts @@ -0,0 +1,2 @@ +export const DYNAMIC_QUESTION_PROMPT_MAX_LENGTH = 120; +export const DYNAMIC_QUESTION_LABEL_MAX_LENGTH = 80; diff --git a/frontend/src/lib/birth-time-journey-dynamic-adapters.ts b/frontend/src/lib/birth-time-journey-dynamic-adapters.ts index de0b4a8b..82237516 100644 --- a/frontend/src/lib/birth-time-journey-dynamic-adapters.ts +++ b/frontend/src/lib/birth-time-journey-dynamic-adapters.ts @@ -4,6 +4,10 @@ import { dynamicChoiceScoringResultSchema, } from "./birth-time-dynamic-choice-internal.ts"; import { candidateResultSchema } from "./birth-time-evidence.ts"; +import { + DYNAMIC_QUESTION_LABEL_MAX_LENGTH, + DYNAMIC_QUESTION_PROMPT_MAX_LENGTH, +} from "./birth-time-dynamic-question-limits.ts"; import type { CandidateDifferenceBuild, DynamicChoiceScoringResult, @@ -17,7 +21,7 @@ const apiRangeSchema = z.object({ const partitionSchema = z.object({ partition_id: z.string().trim().min(1), descriptor: z.string().trim().min(1), - fallback_label: z.string().trim().min(1).max(80), + fallback_label: z.string().trim().min(1).max(DYNAMIC_QUESTION_LABEL_MAX_LENGTH), candidate_scores: z.record(candidateTimeSchema, z.number().finite().nonnegative()), }).strict(); const opportunitySchema = z.object({ @@ -26,7 +30,7 @@ const opportunitySchema = z.object({ neutral_context: z.string().trim().min(1), estimated_information_gain: z.number().finite().nonnegative(), candidate_partition_fingerprint: z.string().trim().min(1), - fallback_prompt: z.string().trim().min(1).max(240), + fallback_prompt: z.string().trim().min(1).max(DYNAMIC_QUESTION_PROMPT_MAX_LENGTH), partitions: z.array(partitionSchema).min(2).max(4), }).strict().superRefine((value, context) => { if (new Set(value.partitions.map((item) => item.partition_id)).size !== value.partitions.length) { diff --git a/frontend/tests/birth-time-dynamic-choice.test.ts b/frontend/tests/birth-time-dynamic-choice.test.ts index 387b44e8..a17228ab 100644 --- a/frontend/tests/birth-time-dynamic-choice.test.ts +++ b/frontend/tests/birth-time-dynamic-choice.test.ts @@ -3,7 +3,10 @@ import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import test from "node:test"; import { publicDynamicChoiceQuestionSchema } from "../src/lib/birth-time-dynamic-choice.ts"; -import { persistedDynamicChoiceQuestionSchema } from "../src/lib/birth-time-dynamic-choice-internal.ts"; +import { + persistedDynamicChoiceQuestionSchema, + toPublicDynamicChoiceQuestion, +} from "../src/lib/birth-time-dynamic-choice-internal.ts"; import { dynamicJourneyTurnStateSchema, journeyTurnStateSchema } from "../src/lib/birth-time-journey-turn-protocol.ts"; const internalQuestion = { @@ -74,6 +77,20 @@ test("public questions never expose partition ids", () => { }).success, false); }); +test("public questions enforce the shared prompt limit", () => { + const publicQuestion = toPublicDynamicChoiceQuestion(internalQuestion); + const promptWithLength = (length: number) => `${"问".repeat(length - 1)}?`; + + assert.equal(publicDynamicChoiceQuestionSchema.safeParse({ + ...publicQuestion, + prompt: promptWithLength(120), + }).success, true); + assert.equal(publicDynamicChoiceQuestionSchema.safeParse({ + ...publicQuestion, + prompt: promptWithLength(121), + }).success, false); +}); + test("internal primary choices require a server partition", () => { assert.equal(persistedDynamicChoiceQuestionSchema.safeParse(internalQuestion).success, true); assert.equal(persistedDynamicChoiceQuestionSchema.safeParse({ diff --git a/frontend/tests/birth-time-dynamic-question-service.test.ts b/frontend/tests/birth-time-dynamic-question-service.test.ts index 544165a1..f78c8eb0 100644 --- a/frontend/tests/birth-time-dynamic-question-service.test.ts +++ b/frontend/tests/birth-time-dynamic-question-service.test.ts @@ -213,6 +213,31 @@ test("reserved-label collisions propagate without commit or ID allocation", asyn } }); +test("overlong server prompts propagate without commit or ID allocation", async () => { + const opportunity = differenceBuild.packet.opportunities[0]; + if (!opportunity) throw new Error("missing test opportunity"); + for (const length of [121, 240]) { + let allocations = 0; + let commits = 0; + const fallbackPrompt = `${"问".repeat(length - 1)}?`; + + await assert.rejects(() => dynamicService({ + build: { + ...differenceBuild, + packet: { + ...differenceBuild.packet, + opportunities: [{ ...opportunity, fallbackPrompt }], + }, + }, + generator: generatorFrom(() => JSON.stringify(validDynamicSelection)), + createId: deterministicIds(() => { allocations += 1; }), + onCommit: () => { commits += 1; }, + }).generateQuestion("owner-1", generationCommand), BirthTimeDynamicBindingError); + assert.equal(allocations, 0, String(length)); + assert.equal(commits, 0, String(length)); + } +}); + test("invalid server UUIDs propagate without committing a low result", async () => { let commits = 0; await assert.rejects(() => dynamicService({ diff --git a/frontend/tests/birth-time-dynamic-question-validator.test.ts b/frontend/tests/birth-time-dynamic-question-validator.test.ts index 221e89c8..67735369 100644 --- a/frontend/tests/birth-time-dynamic-question-validator.test.ts +++ b/frontend/tests/birth-time-dynamic-question-validator.test.ts @@ -139,6 +139,33 @@ test("primary labels cannot collide with either reserved visible choice", () => } }); +test("server prompt accepts 120 characters but rejects longer copy before IDs", () => { + const opportunity = dynamicPacket.opportunities[0]; + if (!opportunity) throw new Error("missing test opportunity"); + const promptWithLength = (length: number) => `${"问".repeat(length - 1)}?`; + const buildWithPrompt = (length: number) => ({ + ...differenceBuild, + packet: { + ...dynamicPacket, + opportunities: [{ ...opportunity, fallbackPrompt: promptWithLength(length) }], + }, + }); + + assert.equal( + bindDynamicQuestion(validDynamicSelection, buildWithPrompt(120), deterministicIds()).prompt.length, + 120, + ); + for (const length of [121, 240]) { + let allocations = 0; + assert.throws(() => bindDynamicQuestion( + validDynamicSelection, + buildWithPrompt(length), + deterministicIds(() => { allocations += 1; }), + ), BirthTimeDynamicBindingError); + assert.equal(allocations, 0, String(length)); + } +}); + test("repeated server semantics and partitions remain recoverable rejections", () => { const selection = parseDynamicQuestionOutput(validDynamicSelection, dynamicPacket); if (selection.kind !== "question") throw new Error("expected a selection"); diff --git a/frontend/tests/birth-time-journey-dynamic-adapters.test.ts b/frontend/tests/birth-time-journey-dynamic-adapters.test.ts index cc66d840..b6c4194e 100644 --- a/frontend/tests/birth-time-journey-dynamic-adapters.test.ts +++ b/frontend/tests/birth-time-journey-dynamic-adapters.test.ts @@ -85,6 +85,19 @@ test("difference packets separate public copy from private score vectors", () => }); }); +test("difference packets enforce the public prompt limit at the API boundary", () => { + const withPrompt = (length: number) => ({ + ...apiPacket, + opportunities: [{ + ...apiPacket.opportunities[0], + fallback_prompt: `${"问".repeat(length - 1)}?`, + }], + }); + + assert.equal(parseCandidateDifferenceBuild(withPrompt(120)).packet.opportunities[0]?.fallbackPrompt.length, 120); + assert.throws(() => parseCandidateDifferenceBuild(withPrompt(121))); +}); + test("difference packets reject wrong versions, extra fields, and invalid score keys", () => { assert.throws(() => parseCandidateDifferenceBuild({ ...apiPacket, scoring_version: "birth-time-choice-scoring-v1", diff --git a/scripts/dynamic_rectification_copy.py b/scripts/dynamic_rectification_copy.py index 7fa63268..c41cdeee 100644 --- a/scripts/dynamic_rectification_copy.py +++ b/scripts/dynamic_rectification_copy.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Sequence from datetime import date -from typing import Final, TypedDict +from typing import Final, Literal, TypedDict, assert_never DIMENSION_CONTEXT: Final = { "education": "一次明显的升学、转学或学习方向变化", @@ -21,21 +21,28 @@ class DateRange(TypedDict): window_end: str -def _range_label(item: DateRange, precision: str) -> str: +RangePrecision = Literal["year", "month", "day"] + + +def _range_label(item: DateRange, precision: RangePrecision) -> str: start = date.fromisoformat(item["window_start"]) end = date.fromisoformat(item["window_end"]) - if precision == "year": - return f"{start.year} 年" if start.year == end.year else f"{start.year}—{end.year} 年" - if precision == "month": - if start.year == end.year: - return f"{start.year} 年 {start.month} 月—{end.month} 月" - return f"{start.year} 年 {start.month} 月—{end.year} 年 {end.month} 月" - if start.year == end.year and start.month == end.month: - return f"{start.year} 年 {start.month} 月 {start.day} 日—{end.day} 日" - return ( - f"{start.year} 年 {start.month} 月 {start.day} 日—" - f"{end.year} 年 {end.month} 月 {end.day} 日" - ) + match precision: + case "year": + return f"{start.year} 年" if start.year == end.year else f"{start.year}—{end.year} 年" + case "month": + if start.year == end.year: + return f"{start.year} 年 {start.month} 月—{end.month} 月" + return f"{start.year} 年 {start.month} 月—{end.year} 年 {end.month} 月" + case "day": + if start.year == end.year and start.month == end.month: + return f"{start.year} 年 {start.month} 月 {start.day} 日—{end.day} 日" + return ( + f"{start.year} 年 {start.month} 月 {start.day} 日—" + f"{end.year} 年 {end.month} 月 {end.day} 日" + ) + case unreachable: + assert_never(unreachable) def visible_range_labels(items: Sequence[DateRange]) -> list[str]: diff --git a/tests/test_dynamic_rectification_opportunities.py b/tests/test_dynamic_rectification_opportunities.py index ff839a7e..6fe0bf4f 100644 --- a/tests/test_dynamic_rectification_opportunities.py +++ b/tests/test_dynamic_rectification_opportunities.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import re +import unicodedata from pathlib import Path from scripts.dynamic_rectification_opportunities import _dimension_opportunity @@ -44,7 +45,7 @@ def test_every_supported_dimension_emits_distinct_validator_safe_copy() -> None: assert re.search(r"[\u3400-\u9fff]", context) assert re.search(r"[A-Za-z]", context + prompt) is None assert prompt.endswith("?") and prompt.count("?") == 1 - assert len(labels) == len(set(label.replace(" ", "") for label in labels)) + assert len(labels) == len(set(re.sub(r"\s+", "", label) for label in labels)) assert all(re.search(r"[\u3400-\u9fff]", label) for label in labels) @@ -78,7 +79,9 @@ def test_frontend_adapter_fixture_is_real_task2_opportunity_output() -> None: assert len(fixture["partitions"]) == len(opportunity["partitions"]) assert re.search(r"[A-Za-z]", fixture["neutral_context"] + fixture["fallback_prompt"]) is None fixture_labels = [item["fallback_label"] for item in fixture["partitions"]] - assert len(fixture_labels) == len(set(label.replace(" ", "") for label in fixture_labels)) + assert len(fixture_labels) == len( + set(re.sub(r"\s+", "", label) for label in fixture_labels) + ) def test_same_year_windows_receive_distinct_visible_labels() -> None: @@ -99,8 +102,11 @@ def test_same_year_windows_receive_distinct_visible_labels() -> None: assert opportunity is not None labels = [item["fallback_label"] for item in opportunity["partitions"]] - assert len(labels) == len(set(labels)) - assert all("月" in label for label in labels) + normalized = [ + re.sub(r"\s+", "", unicodedata.normalize("NFKC", label)) for label in labels + ] + assert len(normalized) == len(set(normalized)) + assert all(len(re.findall(r"\d+", label)) >= 3 for label in labels) def test_same_month_windows_receive_distinct_day_precision_labels() -> None: @@ -121,5 +127,8 @@ def test_same_month_windows_receive_distinct_day_precision_labels() -> None: assert opportunity is not None labels = [item["fallback_label"] for item in opportunity["partitions"]] - assert len(labels) == len(set(labels)) - assert all("日" in label for label in labels) + normalized = [ + re.sub(r"\s+", "", unicodedata.normalize("NFKC", label)) for label in labels + ] + assert len(normalized) == len(set(normalized)) + assert all(len(re.findall(r"\d+", label)) >= 4 for label in labels)