@nodaro/prompts 1.14.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +680 -251
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +406 -1
- package/dist/index.d.ts +406 -1
- package/dist/index.js +649 -253
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/adult-only-ratchet.test.ts +110 -0
- package/src/__tests__/age-floor.test.ts +347 -0
- package/src/__tests__/catalog-id-guard.test.ts +146 -0
- package/src/__tests__/catalog-overlay.test.ts +163 -0
- package/src/__tests__/doctrine-roster-completeness.test.ts +1 -0
- package/src/__tests__/dod-replace-pack-acceptance.test.ts +17 -0
- package/src/__tests__/fixtures/parameter-hint-golden.json +315 -37
- package/src/__tests__/minor-age-floor.test.ts +136 -0
- package/src/__tests__/person-body-axes.test.ts +2 -2
- package/src/__tests__/person-exposure-hints.test.ts +59 -4
- package/src/__tests__/person-pack-adult-only.test.ts +45 -0
- package/src/__tests__/picker-analyzer-registry.test.ts +14 -0
- package/src/__tests__/retired-hint-strings.test.ts +68 -0
- package/src/__tests__/subject-registry.test.ts +1 -1
- package/src/__tests__/w1b-rephrase.test.ts +119 -0
- package/src/action-fx.ts +2 -1
- package/src/aesthetic.ts +2 -1
- package/src/age-floor.ts +359 -0
- package/src/age-signal.ts +36 -0
- package/src/atmosphere.ts +2 -1
- package/src/backdrop.ts +2 -1
- package/src/camera-format.ts +2 -1
- package/src/camera-motions.ts +2 -1
- package/src/catalog-id-guard.ts +189 -0
- package/src/catalog-overlay.ts +148 -0
- package/src/catalog-packs.ts +27 -2
- package/src/character-fx.ts +2 -1
- package/src/color-look.ts +2 -1
- package/src/composition-effects.ts +2 -1
- package/src/era.ts +2 -1
- package/src/exposure-settings.ts +2 -1
- package/src/framing.ts +2 -1
- package/src/held-prop.ts +2 -1
- package/src/index.ts +4 -0
- package/src/instrumentation.ts +5 -4
- package/src/lens.ts +2 -1
- package/src/lighting.ts +2 -1
- package/src/loop-subject.ts +3 -1
- package/src/materials.ts +2 -1
- package/src/mood.ts +16 -5
- package/src/music-genre.ts +5 -4
- package/src/music-mood.ts +4 -3
- package/src/parameter-prompt-hint.ts +13 -57
- package/src/person-packs.ts +14 -2
- package/src/person.ts +76 -42
- package/src/photo-genre.ts +14 -3
- package/src/photographer.ts +2 -1
- package/src/picker-analyzer-registry.ts +9 -0
- package/src/picker-catalogs.ts +11 -0
- package/src/pose.ts +17 -6
- package/src/post-process-effects.ts +2 -1
- package/src/prompt-builder.ts +3 -3
- package/src/render-quality.ts +2 -1
- package/src/setting.ts +2 -1
- package/src/shared-catalog-overlay.ts +83 -0
- package/src/style.ts +16 -1
- package/src/styling.ts +93 -29
- package/src/subject-registry.ts +2 -2
- package/src/temporal.ts +2 -1
- package/src/transitions.ts +2 -1
- package/src/voice-character.ts +6 -5
- package/src/voice-delivery.ts +4 -3
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { buildPersonHints } from "../person.js"
|
|
3
|
+
import { buildStylingHints } from "../styling.js"
|
|
4
|
+
import { getAdultOnlyEntries, applyMinorAgeFloorToPickerValues, getAdultOnlyHintStrings, ADULT_SWEPT_CATALOG_IDS, FLOORED_PICKER_KEYS } from "../age-floor.js"
|
|
5
|
+
|
|
6
|
+
/** The seven P0 prompts' shape (2026-07-30): a 5-year-old + fitted clothing +
|
|
7
|
+
* parted lips + baby-soft skin + direct gaze. Assembled from picker values —
|
|
8
|
+
* the stored prompts are never replayed. */
|
|
9
|
+
const CHILD = {
|
|
10
|
+
type: "boy-next-door",
|
|
11
|
+
age: "age-young-child",
|
|
12
|
+
frame: "frame-slim",
|
|
13
|
+
lipState: ["lip-state-parted"],
|
|
14
|
+
eyeState: ["eye-state-staring-camera"],
|
|
15
|
+
skinTexture: ["texture-baby-soft"],
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
describe("Layer 1 — the collectors drop adultOnly entries for a minor", () => {
|
|
19
|
+
it("a child with flagged lip/eye/skin picks emits none of their hints, keeps the rest", () => {
|
|
20
|
+
const hints = buildPersonHints({ ...CHILD, bust: "bust-very-full", distinctiveFeature: ["feature-midriff-visible"] } as never)
|
|
21
|
+
const joined = hints.join(", ")
|
|
22
|
+
expect(joined).toContain("a young child around 5 years old")
|
|
23
|
+
expect(joined).toContain("slim")
|
|
24
|
+
expect(joined).not.toMatch(/bust|midriff|cropped hemline|slightly open|mid-sentence/i)
|
|
25
|
+
// eye-state-staring-camera and texture-baby-soft are NOT flagged — they stay.
|
|
26
|
+
expect(joined).toContain("looking directly into the camera")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("compact mode drops the same ids", () => {
|
|
30
|
+
const terms = buildPersonHints({ ...CHILD, bust: "bust-very-full", lipState: ["lip-state-glossy"] } as never, "compact")
|
|
31
|
+
expect(terms.join(", ")).not.toMatch(/bust|glossy/i)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("styling on the flat subject bag drops flagged garments when the bag says minor", () => {
|
|
35
|
+
const hints = buildStylingHints({ age: "age-child", wardrobeState: ["state-fitted"], top: "top-crop-top", outfit: "outfit-bikini" } as never)
|
|
36
|
+
const joined = hints.join(", ")
|
|
37
|
+
expect(joined).not.toMatch(/close-fitting|bikini/i)
|
|
38
|
+
expect(joined).toMatch(/cropped top/i) // top-crop-top is deliberately NOT flagged
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("styling with no age signal on the bag is untouched (separate-node consumers; Layer 2 covers them)", () => {
|
|
42
|
+
const hints = buildStylingHints({ wardrobeState: ["state-fitted"] } as never)
|
|
43
|
+
expect(hints.join(", ")).toMatch(/close-fitting/i)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("adults are byte-identical to before: every flagged entry still emits for age-30s", () => {
|
|
47
|
+
for (const e of getAdultOnlyEntries().filter((x) => x.id.startsWith("bust-") || x.id.startsWith("lip-state-"))) {
|
|
48
|
+
const field = e.id.startsWith("bust-") ? "bust" : "lipState"
|
|
49
|
+
const value = field === "bust" ? e.id : [e.id]
|
|
50
|
+
const hints = buildPersonHints({ age: "age-30s", [field]: value } as never)
|
|
51
|
+
expect(hints.join(" "), e.id).toContain(e.promptHint)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("a minor-implying type with no age is floored too", () => {
|
|
56
|
+
const hints = buildPersonHints({ type: "alice-wonderland", bust: "bust-full" } as never)
|
|
57
|
+
expect(hints.join(", ")).not.toMatch(/bust/i)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe("applyMinorAgeFloorToPickerValues", () => {
|
|
62
|
+
it("strips flagged ids from person/styling/pose/mood when the person is a minor", () => {
|
|
63
|
+
const out = applyMinorAgeFloorToPickerValues({
|
|
64
|
+
person: { age: "age-early-teen", bust: "bust-full", lipState: ["lip-state-glossy", "lip-state-bold-red"] },
|
|
65
|
+
styling: { top: "top-bra-top", wardrobeState: ["state-fitted", "state-layered"] },
|
|
66
|
+
pose: { pose: "arched-back" },
|
|
67
|
+
mood: { mood: ["sultry", "calm"] },
|
|
68
|
+
})
|
|
69
|
+
expect(out.person).toEqual({ age: "age-early-teen", lipState: ["lip-state-bold-red"] })
|
|
70
|
+
expect(out.styling).toEqual({ wardrobeState: ["state-layered"] })
|
|
71
|
+
expect(out.pose).toEqual({})
|
|
72
|
+
expect(out.mood).toEqual({ mood: ["calm"] })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("is the identity for an adult", () => {
|
|
76
|
+
const values = { person: { age: "age-30s", bust: "bust-full" }, styling: { top: "top-bra-top" } }
|
|
77
|
+
expect(applyMinorAgeFloorToPickerValues(values)).toBe(values)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// The final-review gap: `photo-genre` is swept for the flag
|
|
81
|
+
// (ADULT_SWEPT_CATALOG_IDS) but the hand-written strip list omitted it, so a
|
|
82
|
+
// minor kept `glamour-portrait` / `brand-helmut-newton`. FLOORED_PICKER_KEYS
|
|
83
|
+
// is derived from the sweep list now, which is what makes this pass.
|
|
84
|
+
it("strips a flagged photo-genre id from a minor's values", () => {
|
|
85
|
+
// The real analyzer field for this picker is `photoGenre`
|
|
86
|
+
// (picker-analyzer-registry.ts) — `stripIds` drops by VALUE, so the key
|
|
87
|
+
// name is immaterial; both shapes are pinned so neither can regress.
|
|
88
|
+
const out = applyMinorAgeFloorToPickerValues({
|
|
89
|
+
person: { age: "age-child" },
|
|
90
|
+
"photo-genre": { photoGenre: "glamour-portrait" },
|
|
91
|
+
})
|
|
92
|
+
expect(out["photo-genre"]).toEqual({})
|
|
93
|
+
|
|
94
|
+
const coordinatorShape = applyMinorAgeFloorToPickerValues({
|
|
95
|
+
person: { age: "age-child" },
|
|
96
|
+
"photo-genre": { genre: "glamour-portrait" },
|
|
97
|
+
})
|
|
98
|
+
expect(coordinatorShape["photo-genre"]).toEqual({})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("an adult keeps the same photo-genre id (identity)", () => {
|
|
102
|
+
const values = { person: { age: "age-30s" }, "photo-genre": { photoGenre: "glamour-portrait" } }
|
|
103
|
+
expect(applyMinorAgeFloorToPickerValues(values)).toBe(values)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// Totality: the strip list can never again be a subset of the sweep list.
|
|
107
|
+
it("every swept catalog is a floored picker key, and person leads the list", () => {
|
|
108
|
+
for (const id of ADULT_SWEPT_CATALOG_IDS) {
|
|
109
|
+
expect(FLOORED_PICKER_KEYS as ReadonlyArray<string>, id).toContain(id)
|
|
110
|
+
}
|
|
111
|
+
expect(FLOORED_PICKER_KEYS[0]).toBe("person")
|
|
112
|
+
expect(FLOORED_PICKER_KEYS).toHaveLength(ADULT_SWEPT_CATALOG_IDS.length + 1)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe("getAdultOnlyHintStrings", () => {
|
|
117
|
+
it("contains the seven-prompt trigger clause and is longest-first", () => {
|
|
118
|
+
const strings = getAdultOnlyHintStrings()
|
|
119
|
+
expect(strings).toContain("the clothing fitted and form-conscious, hugging the contours of the body")
|
|
120
|
+
expect(strings.length).toBeGreaterThan(0)
|
|
121
|
+
for (let i = 1; i < strings.length; i++) expect(strings[i - 1].length).toBeGreaterThanOrEqual(strings[i].length)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("excludes bare derived terms that are absent from every promptHint (fix round 1: promptHint-only)", () => {
|
|
125
|
+
const strings = getAdultOnlyHintStrings()
|
|
126
|
+
// "lounging" (pose id/label, no explicit term — deriveTerm(label) would
|
|
127
|
+
// back-fill it in the composed catalog) and "cropped top" (state-cropped's
|
|
128
|
+
// explicit `term`, distinct from its promptHint "the top cropped above the
|
|
129
|
+
// midriff with the stomach visible") are exactly the short, generic strings
|
|
130
|
+
// that over-strip unrelated benign text when swept as needles.
|
|
131
|
+
expect(strings).not.toContain("lounging")
|
|
132
|
+
expect(strings).not.toContain("cropped top")
|
|
133
|
+
expect(strings).not.toContain("school uniform")
|
|
134
|
+
expect(strings).not.toContain("lying down")
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -18,7 +18,7 @@ describe("buildPersonHints — body proportion axes", () => {
|
|
|
18
18
|
silhouette: "silhouette-hourglass",
|
|
19
19
|
})
|
|
20
20
|
expect(hints.join(", ")).toBe(
|
|
21
|
-
"slim, lean build, full bust, defined
|
|
21
|
+
"slim, lean build, full bust, a defined waistline, an hourglass figure",
|
|
22
22
|
)
|
|
23
23
|
})
|
|
24
24
|
|
|
@@ -56,7 +56,7 @@ describe("buildPersonHints — body proportion axes", () => {
|
|
|
56
56
|
})
|
|
57
57
|
|
|
58
58
|
it("Bust:Very Full emits the distinct very-full token", () => {
|
|
59
|
-
expect(buildPersonHints({ bust: "bust-very-full" })).toEqual(["
|
|
59
|
+
expect(buildPersonHints({ bust: "bust-very-full" })).toEqual(["a fuller bust"])
|
|
60
60
|
})
|
|
61
61
|
|
|
62
62
|
it("ignores stored values for the retired Build / Body Proportions keys (no migration)", () => {
|
|
@@ -14,15 +14,15 @@ describe("exposure hint collapsing", () => {
|
|
|
14
14
|
const hints = buildPersonHints({
|
|
15
15
|
distinctiveFeature: ["feature-midriff-visible", "feature-navel-visible"],
|
|
16
16
|
} as never)
|
|
17
|
-
const exposure = hints.filter((h) => /
|
|
18
|
-
expect(exposure).toEqual(["
|
|
17
|
+
const exposure = hints.filter((h) => /cropped hemline|navel|stomach/i.test(h))
|
|
18
|
+
expect(exposure).toEqual(["with a cropped hemline and the navel showing"])
|
|
19
19
|
})
|
|
20
20
|
|
|
21
21
|
it("each alone keeps its own (softened, garment-language) hint", () => {
|
|
22
22
|
const midriffOnly = buildPersonHints({
|
|
23
23
|
distinctiveFeature: ["feature-midriff-visible"],
|
|
24
24
|
} as never)
|
|
25
|
-
expect(midriffOnly).toContain("
|
|
25
|
+
expect(midriffOnly).toContain("with a cropped hemline")
|
|
26
26
|
expect(midriffOnly.join(" ")).not.toMatch(/bare stomach/i)
|
|
27
27
|
|
|
28
28
|
const navelOnly = buildPersonHints({
|
|
@@ -40,7 +40,7 @@ describe("exposure hint collapsing", () => {
|
|
|
40
40
|
"feature-freckles",
|
|
41
41
|
],
|
|
42
42
|
} as never)
|
|
43
|
-
expect(hints.filter((h) => /
|
|
43
|
+
expect(hints.filter((h) => /cropped hemline/i.test(h))).toHaveLength(1)
|
|
44
44
|
expect(hints.length).toBeGreaterThan(1)
|
|
45
45
|
})
|
|
46
46
|
})
|
|
@@ -59,3 +59,58 @@ describe("bold-lips cross-catalog dedupe", () => {
|
|
|
59
59
|
expect(hints.join(" ")).toMatch(/bold lips/i)
|
|
60
60
|
})
|
|
61
61
|
})
|
|
62
|
+
|
|
63
|
+
describe("cropped-clause de-stack across the person↔styling boundary (W1-b)", () => {
|
|
64
|
+
it("the midriff person clause suppresses BOTH styling cropped twins on the shared bag", () => {
|
|
65
|
+
const hints = buildStylingHints({
|
|
66
|
+
distinctiveFeature: ["feature-midriff-visible"],
|
|
67
|
+
top: "top-crop-top",
|
|
68
|
+
wardrobeState: ["state-cropped"],
|
|
69
|
+
} as never)
|
|
70
|
+
const joined = hints.join(", ")
|
|
71
|
+
expect(joined).not.toMatch(/cropped top|cropped above the midriff/i)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("without the person clause, the garment wins and the modifier yields", () => {
|
|
75
|
+
const hints = buildStylingHints({
|
|
76
|
+
top: "top-crop-top",
|
|
77
|
+
wardrobeState: ["state-cropped"],
|
|
78
|
+
} as never)
|
|
79
|
+
const joined = hints.join(", ")
|
|
80
|
+
expect(joined).toMatch(/wearing a cropped top that ends above the midriff/i)
|
|
81
|
+
expect(joined).not.toMatch(/the top cropped above the midriff with the stomach visible/i)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("a MINOR who picks midriff + crop-top still gets exactly one cropped clause", () => {
|
|
85
|
+
// The person clause is dropped by the floor (feature-midriff-visible is
|
|
86
|
+
// adultOnly), so the styling twin must NOT be suppressed — otherwise the
|
|
87
|
+
// floor silently deletes a garment detail instead of a body detail.
|
|
88
|
+
const hints = buildStylingHints({
|
|
89
|
+
age: "age-child",
|
|
90
|
+
distinctiveFeature: ["feature-midriff-visible"],
|
|
91
|
+
top: "top-crop-top",
|
|
92
|
+
} as never)
|
|
93
|
+
expect(hints.join(", ")).toMatch(/wearing a cropped top that ends above the midriff/i)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("either styling id alone is untouched (no person clause, no twin)", () => {
|
|
97
|
+
expect(buildStylingHints({ wardrobeState: ["state-cropped"] } as never).join(", "))
|
|
98
|
+
.toMatch(/the top cropped above the midriff/i)
|
|
99
|
+
expect(buildStylingHints({ top: "top-crop-top" } as never).join(", "))
|
|
100
|
+
.toMatch(/wearing a cropped top/i)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("de-stacks a BARE-STRING wardrobeState too, not just the array form", () => {
|
|
104
|
+
// `wardrobeState` is a single-pick-or-multi field (string | string[]), and
|
|
105
|
+
// `normalizeSubjectFields` unwraps a lone array pick to a bare string
|
|
106
|
+
// before the styling collector ever sees it — the single-pick case is the
|
|
107
|
+
// common one, not an edge case, so the suppression must not be array-only.
|
|
108
|
+
const hints = buildStylingHints({
|
|
109
|
+
distinctiveFeature: "feature-midriff-visible",
|
|
110
|
+
top: "top-crop-top",
|
|
111
|
+
wardrobeState: "state-cropped",
|
|
112
|
+
} as never)
|
|
113
|
+
const joined = hints.join(", ")
|
|
114
|
+
expect(joined).not.toMatch(/cropped top|cropped above the midriff/i)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest"
|
|
2
|
+
import { registerPersonPack, resetPersonPacks, getRegisteredPeople } from "../person-packs.js"
|
|
3
|
+
import { registerCatalogPack, resetCatalogPacks } from "../catalog-packs.js"
|
|
4
|
+
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
resetPersonPacks()
|
|
7
|
+
resetCatalogPacks()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
describe("person packs carry adultOnly", () => {
|
|
11
|
+
it("an extend person pack entry keeps its flag in getRegisteredPeople()", () => {
|
|
12
|
+
registerPersonPack({
|
|
13
|
+
id: "test/person-extend",
|
|
14
|
+
dimensions: [{ dimension: "vibe", field: "vibe", label: "Vibe" }],
|
|
15
|
+
entries: [
|
|
16
|
+
{ id: "vibe-boudoir", label: "Boudoir", dimension: "vibe", description: "Boudoir styling", promptHint: "in a boudoir setting", adultOnly: true },
|
|
17
|
+
{ id: "vibe-picnic", label: "Picnic", dimension: "vibe", description: "Picnic styling", promptHint: "at a picnic" },
|
|
18
|
+
],
|
|
19
|
+
})
|
|
20
|
+
const people = getRegisteredPeople()
|
|
21
|
+
expect(people.find((p) => p.id === "vibe-boudoir")?.adultOnly).toBe(true)
|
|
22
|
+
expect(people.find((p) => p.id === "vibe-picnic")?.adultOnly).toBeUndefined()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("a replace catalog pack for person keeps the flag through personEntriesFromDims", () => {
|
|
26
|
+
registerCatalogPack({
|
|
27
|
+
id: "test/person-replace",
|
|
28
|
+
catalogId: "person",
|
|
29
|
+
mode: "replace",
|
|
30
|
+
catalog: {
|
|
31
|
+
catalogId: "person",
|
|
32
|
+
kind: "multi",
|
|
33
|
+
label: "Person",
|
|
34
|
+
dimensions: [
|
|
35
|
+
{
|
|
36
|
+
field: "bust",
|
|
37
|
+
label: "Bust",
|
|
38
|
+
options: [{ id: "bust-very-full", label: "Very Full", promptHint: "very full bust", adultOnly: true }],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
} as never,
|
|
42
|
+
})
|
|
43
|
+
expect(getRegisteredPeople().find((p) => p.id === "bust-very-full")?.adultOnly).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import {
|
|
8
8
|
buildPickerAnalyzerSpec,
|
|
9
9
|
buildPickerZodSchema,
|
|
10
|
+
PICKER_ANALYZER_REGISTRY,
|
|
10
11
|
} from "../picker-analyzer-registry.js"
|
|
11
12
|
// NOTE: the existing test file already imports `buildPickerAnalyzerSpec` from
|
|
12
13
|
// "../picker-analyzer-registry.js" — do NOT re-import it here (duplicate-identifier
|
|
@@ -116,3 +117,16 @@ describe("registry invariants (all analyzable pickers)", () => {
|
|
|
116
117
|
expect(buildPickerAnalyzerSpec("lens").dimensions[0].entryIds).toEqual(LENSES.map((l) => l.id))
|
|
117
118
|
})
|
|
118
119
|
})
|
|
120
|
+
|
|
121
|
+
describe("personCleanup (W1-a: minor-age floor)", () => {
|
|
122
|
+
it("drops flagged ids from the patch when the patch itself is a minor", () => {
|
|
123
|
+
const patch: Record<string, unknown> = {
|
|
124
|
+
age: "age-child",
|
|
125
|
+
bust: "bust-full",
|
|
126
|
+
lipState: ["lip-state-glossy", "lip-state-bold-red"],
|
|
127
|
+
}
|
|
128
|
+
PICKER_ANALYZER_REGISTRY.person.cleanup?.(patch, "fill-empty")
|
|
129
|
+
expect(patch.bust).toBeUndefined()
|
|
130
|
+
expect(patch.lipState).toEqual(["lip-state-bold-red"])
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { getAdultOnlyHintStrings, RETIRED_ADULT_ONLY_HINT_STRINGS } from "../age-floor.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* W1-b (spec 2026-09-01 §3.3) rewords 17 catalog hints that W1-a (PR 2) had
|
|
6
|
+
* flagged `adultOnly`. The backend `minor-age-floor` policy strips sentences
|
|
7
|
+
* by STRING, so a rewording silently narrows the floor for every consumer
|
|
8
|
+
* still on the pre-rephrase `@nodaro/prompts` — including person.nodaro.ai,
|
|
9
|
+
* whose client-assembled `seedPrompt` is precisely how the 2026-07-30 P0
|
|
10
|
+
* prompts carried `state-fitted`'s clause to the provider.
|
|
11
|
+
*
|
|
12
|
+
* The retired strings therefore stay in the strip set forever. A retired
|
|
13
|
+
* string can only over-strip, and only for a subject already judged a minor.
|
|
14
|
+
*/
|
|
15
|
+
describe("retired adult-only wording stays inside the floor", () => {
|
|
16
|
+
it("every retired string is still returned by getAdultOnlyHintStrings()", () => {
|
|
17
|
+
const live = new Set(getAdultOnlyHintStrings())
|
|
18
|
+
const missing = RETIRED_ADULT_ONLY_HINT_STRINGS.filter((s) => !live.has(s.toLowerCase()))
|
|
19
|
+
expect(missing, "retired strings dropped out of the floor").toEqual([])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("the 2026-07-30 incident clause is in the set by name", () => {
|
|
23
|
+
expect(getAdultOnlyHintStrings()).toContain(
|
|
24
|
+
"the clothing fitted and form-conscious, hugging the contours of the body",
|
|
25
|
+
)
|
|
26
|
+
expect(getAdultOnlyHintStrings()).toContain("wearing a cropped style, midriff and navel visible")
|
|
27
|
+
expect(getAdultOnlyHintStrings()).toContain("with lips slightly parted, taking a soft breath")
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("the NEW wording is a live needle too — the floor tracks the rephrase forward", () => {
|
|
31
|
+
// The derivation makes this true by construction, but nothing else states
|
|
32
|
+
// it: a future edit that drops a hint under the `length >= 8` filter, or
|
|
33
|
+
// an entry quietly losing `adultOnly`, would narrow the floor silently.
|
|
34
|
+
// (Green only after Task 5; see that task's Step 7 run set.)
|
|
35
|
+
const strings = getAdultOnlyHintStrings()
|
|
36
|
+
expect(strings).toContain("tailored, close-fitting clothing")
|
|
37
|
+
expect(strings).toContain("with a cropped hemline")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("no bare derived TERM leaked into the needle set (the promptHint-only contract)", () => {
|
|
41
|
+
const strings = getAdultOnlyHintStrings()
|
|
42
|
+
for (const t of ["lounging", "cropped top", "school uniform", "lying down", "wet clothing", "bare shoulders"]) {
|
|
43
|
+
expect(strings, `term "${t}" must never be a needle`).not.toContain(t)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("every retired string is lower-case and long enough to survive the length filter", () => {
|
|
48
|
+
for (const s of RETIRED_ADULT_ONLY_HINT_STRINGS) {
|
|
49
|
+
expect(s, s).toBe(s.toLowerCase())
|
|
50
|
+
expect(s.length, s).toBeGreaterThanOrEqual(8)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("the set covers all 15 flagged rephrased entries plus the fold literal", () => {
|
|
55
|
+
// 15 flagged promptHints (the 14 flagged person/styling entries plus the
|
|
56
|
+
// pose twin) + the hard-coded fold literal = 16. `promptHint`s only —
|
|
57
|
+
// never terms. A change to the count must be a deliberate edit here, not
|
|
58
|
+
// a silent drift.
|
|
59
|
+
expect(RETIRED_ADULT_ONLY_HINT_STRINGS.length).toBe(16)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("the list is longest-first, matching getAdultOnlyHintStrings' contract", () => {
|
|
63
|
+
const strings = getAdultOnlyHintStrings()
|
|
64
|
+
for (let i = 1; i < strings.length; i++) {
|
|
65
|
+
expect(strings[i - 1].length).toBeGreaterThanOrEqual(strings[i].length)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -218,7 +218,7 @@ describe("renderSubjectHints — the flat-bag behaviors", () => {
|
|
|
218
218
|
{ distinctiveFeature: ["feature-midriff-visible", "feature-navel-visible"] },
|
|
219
219
|
IMAGE,
|
|
220
220
|
)
|
|
221
|
-
expect(out).toEqual(["
|
|
221
|
+
expect(out).toEqual(["with a cropped hemline and the navel showing"])
|
|
222
222
|
})
|
|
223
223
|
|
|
224
224
|
it("de-duplicates an exact repeated clause, first occurrence winning", () => {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { PEOPLE } from "../person.js"
|
|
3
|
+
import { STYLINGS } from "../styling.js"
|
|
4
|
+
import { POSES } from "../pose.js"
|
|
5
|
+
import { PICKER_CATALOGS } from "../picker-catalogs.js"
|
|
6
|
+
import { RETIRED_ADULT_ONLY_HINT_STRINGS } from "../age-floor.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* W1-b — spec 2026-09-01-app-reports-triage-design.md §3.3, wording approved
|
|
10
|
+
* by §10 decisions 1 and 6.
|
|
11
|
+
*
|
|
12
|
+
* 12 adult `generate-character` prompts were refused by the provider's safety
|
|
13
|
+
* filter because stacked picker fragments read as sexualized. The principle:
|
|
14
|
+
* describe the garment or the look, not exposed skin; drop
|
|
15
|
+
* "wet / sheen / exposed / bare / hugging / soft breath"; keep the attribute
|
|
16
|
+
* renderable.
|
|
17
|
+
*
|
|
18
|
+
* SCOPE RULING, LATER RESOLVED (replay-harness verdict, internal validation
|
|
19
|
+
* results, 2026-09-02): the go/no-go criterion initially failed for TWO
|
|
20
|
+
* entries — `eye-state-half-lidded` and `feature-collarbone-visible`
|
|
21
|
+
* rendered their attribute 0/10 and 0/22 times under either wording, so
|
|
22
|
+
* their approved rewording could not be validated at the time and both
|
|
23
|
+
* shipped byte-identical in the original W1-b PR. A follow-up replay on
|
|
24
|
+
* staging found wordings that DO render, approved 2026-09-03. Both are now
|
|
25
|
+
* included in PERSON_EXPECTED below with BOTH `promptHint` and `term`
|
|
26
|
+
* reworded, like every other entry — the compact-mode term is not left
|
|
27
|
+
* carrying the old wording.
|
|
28
|
+
*
|
|
29
|
+
* This file pins BOTH fields of every reworded entry — `promptHint` (verbose
|
|
30
|
+
* mode) and `term` (compact mode) — because a rephrase that touched only one
|
|
31
|
+
* of them would leave the old wording reachable through the other. The last
|
|
32
|
+
* test is the ratchet: no retired string may reappear anywhere in the
|
|
33
|
+
* catalog corpus.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
interface Expected {
|
|
37
|
+
readonly id: string
|
|
38
|
+
readonly promptHint: string
|
|
39
|
+
readonly term: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PERSON_EXPECTED: ReadonlyArray<Expected> = [
|
|
43
|
+
{ id: "bust-very-full", promptHint: "a fuller bust", term: "fuller bust" },
|
|
44
|
+
{ id: "silhouette-hourglass", promptHint: "an hourglass figure", term: "hourglass figure" },
|
|
45
|
+
{ id: "waist-defined", promptHint: "a defined waistline", term: "defined waistline" },
|
|
46
|
+
{ id: "lip-state-glossy", promptHint: "with a glossy lip finish", term: "glossy lips" },
|
|
47
|
+
{ id: "lip-state-parted", promptHint: "with lips relaxed and slightly open, as if mid-sentence", term: "lips slightly open" },
|
|
48
|
+
{ id: "lip-state-bitten", promptHint: "lightly biting the lower lip", term: "biting lower lip" },
|
|
49
|
+
{ id: "eye-state-staring-camera", promptHint: "looking directly into the camera", term: "direct gaze to camera" },
|
|
50
|
+
{ id: "eye-state-half-lidded", promptHint: "with drowsy, partly closed eyes, the lids sitting low over the iris", term: "drowsy, partly closed eyes" },
|
|
51
|
+
{ id: "texture-dewy", promptHint: "with dewy, luminous skin", term: "dewy skin" },
|
|
52
|
+
{ id: "texture-glistening", promptHint: "with a light glistening sheen on the skin", term: "glistening sheen" },
|
|
53
|
+
{ id: "texture-baby-soft", promptHint: "with soft, fine-pored skin", term: "soft fine-pored skin" },
|
|
54
|
+
{ id: "texture-shower-fresh-wet", promptHint: "with fresh, water-dappled skin as if just out of the shower", term: "water-dappled skin" },
|
|
55
|
+
{ id: "feature-bare-shoulders", promptHint: "with the shoulders uncovered", term: "shoulders uncovered" },
|
|
56
|
+
{ id: "feature-collarbone-visible", promptHint: "with an open neckline that leaves the collarbones uncovered", term: "open neckline, collarbones uncovered" },
|
|
57
|
+
{ id: "feature-midriff-visible", promptHint: "with a cropped hemline", term: "cropped hemline" },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
const STYLING_EXPECTED: ReadonlyArray<Expected> = [
|
|
61
|
+
{ id: "state-fitted", promptHint: "tailored, close-fitting clothing", term: "close-fitting clothing" },
|
|
62
|
+
{ id: "state-wet", promptHint: "the clothing soaked through and dripping", term: "soaked clothing" },
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
const POSE_EXPECTED: ReadonlyArray<Expected> = [
|
|
66
|
+
{ id: "biting-lip", promptHint: "lightly biting the lower lip, a subtle playful expression", term: "biting lower lip" },
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
function check(catalog: ReadonlyArray<{ id: string; promptHint: string; term?: string }>, expected: ReadonlyArray<Expected>) {
|
|
70
|
+
for (const want of expected) {
|
|
71
|
+
const entry = catalog.find((e) => e.id === want.id)
|
|
72
|
+
expect(entry, `missing entry ${want.id}`).toBeDefined()
|
|
73
|
+
expect(entry!.promptHint, `${want.id}.promptHint`).toBe(want.promptHint)
|
|
74
|
+
expect(entry!.term, `${want.id}.term (authored, not label-derived)`).toBe(want.term)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("W1-b rephrase — the 17 reworded entries plus the pose twin", () => {
|
|
79
|
+
it("person entries carry the approved promptHint and term", () => {
|
|
80
|
+
check(PEOPLE, PERSON_EXPECTED)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("eye-state-half-lidded and feature-collarbone-visible keep adultOnly through the rework", () => {
|
|
84
|
+
const halfLidded = PEOPLE.find((e) => e.id === "eye-state-half-lidded")
|
|
85
|
+
expect(halfLidded?.adultOnly).toBe(true)
|
|
86
|
+
|
|
87
|
+
const collarbone = PEOPLE.find((e) => e.id === "feature-collarbone-visible")
|
|
88
|
+
expect(collarbone?.adultOnly).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("styling entries carry the approved promptHint and term", () => {
|
|
92
|
+
check(STYLINGS, STYLING_EXPECTED)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("the pose twin of lip-state-bitten is reworded to match", () => {
|
|
96
|
+
check(POSES, POSE_EXPECTED)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("all 18 ids are covered — the count is deliberate, not incidental", () => {
|
|
100
|
+
expect(PERSON_EXPECTED.length + STYLING_EXPECTED.length + POSE_EXPECTED.length).toBe(18)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("RATCHET: no retired string survives anywhere in the catalog corpus", () => {
|
|
104
|
+
const offenders: string[] = []
|
|
105
|
+
for (const cat of PICKER_CATALOGS) {
|
|
106
|
+
const options = [...(cat.options ?? []), ...(cat.dimensions ?? []).flatMap((d) => d.options)]
|
|
107
|
+
for (const o of options) {
|
|
108
|
+
for (const retired of RETIRED_ADULT_ONLY_HINT_STRINGS) {
|
|
109
|
+
// The fold literal is not an entry field; it is pinned separately in
|
|
110
|
+
// person-exposure-hints.test.ts.
|
|
111
|
+
if (o.promptHint === retired || o.term === retired) {
|
|
112
|
+
offenders.push(`${cat.catalogId} • ${o.id} • "${retired}"`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
expect(offenders, `retired wording is still live:\n${offenders.join("\n")}`).toEqual([])
|
|
118
|
+
})
|
|
119
|
+
})
|
package/src/action-fx.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { resolveTerm, type PickerHintMode } from "./term.js"
|
|
21
|
+
import { overlayEntry } from "./catalog-overlay.js"
|
|
21
22
|
|
|
22
23
|
export type ActionFxCategory =
|
|
23
24
|
| "disaster"
|
|
@@ -224,7 +225,7 @@ const actionFxById = new Map<string, ActionFx>(ACTION_FX.map((fx) => [fx.id, fx]
|
|
|
224
225
|
|
|
225
226
|
export function getActionFx(id: string | undefined | null): ActionFx | undefined {
|
|
226
227
|
if (!id) return undefined
|
|
227
|
-
return actionFxById.get(id)
|
|
228
|
+
return overlayEntry("action-fx", id, actionFxById.get(id))
|
|
228
229
|
}
|
|
229
230
|
|
|
230
231
|
export function getActionFxLabel(id: string | undefined | null, fallback?: string): string {
|
package/src/aesthetic.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { resolveTerm, type PickerHintMode } from "./term.js"
|
|
23
|
+
import { overlayEntry } from "./catalog-overlay.js"
|
|
23
24
|
|
|
24
25
|
export type AestheticCategory = "mainstream" | "niche" | "era" | "mood"
|
|
25
26
|
|
|
@@ -400,7 +401,7 @@ const aestheticById = new Map<string, Aesthetic>(
|
|
|
400
401
|
|
|
401
402
|
export function getAesthetic(id: string | undefined | null): Aesthetic | undefined {
|
|
402
403
|
if (!id) return undefined
|
|
403
|
-
return aestheticById.get(id)
|
|
404
|
+
return overlayEntry("aesthetic", id, aestheticById.get(id))
|
|
404
405
|
}
|
|
405
406
|
|
|
406
407
|
export function getAestheticLabel(id: string | undefined | null, fallback?: string): string {
|