@nodaro/shared 2.15.0 → 2.17.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 +178 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +378 -16
- package/dist/index.d.ts +378 -16
- package/dist/index.js +167 -15
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/entity-mention-slug.test.ts +380 -0
- package/src/__tests__/image-mention-slug.test.ts +303 -0
- package/src/__tests__/producer-types.test.ts +22 -0
- package/src/__tests__/prompt-length-limits.test.ts +2 -2
- package/src/__tests__/to-connected-references.test.ts +45 -0
- package/src/animals.ts +57 -0
- package/src/character-voice.ts +4 -3
- package/src/entity-mention-slug.ts +203 -0
- package/src/image-mention-slug.ts +187 -0
- package/src/index.ts +27 -0
- package/src/mention-token-grammar.ts +167 -0
- package/src/model-catalog.ts +12 -9
- package/src/model-constants.ts +8 -6
- package/src/model-tree.ts +1 -0
- package/src/producer-types.ts +6 -0
- package/src/to-connected-references.ts +16 -2
- package/src/types.ts +11 -5
- package/src/workflow-export.ts +34 -0
package/package.json
CHANGED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
entityMentionSlug,
|
|
4
|
+
parseEntityMentionToken,
|
|
5
|
+
findEntityMentionTokens,
|
|
6
|
+
knownEntitySlugsFromRefs,
|
|
7
|
+
entityMentionSlugForRef,
|
|
8
|
+
} from "../entity-mention-slug.js"
|
|
9
|
+
import {
|
|
10
|
+
findImageMentionTokens,
|
|
11
|
+
imageMentionSlug,
|
|
12
|
+
imageMentionSlugForRef,
|
|
13
|
+
parseImageMentionToken,
|
|
14
|
+
} from "../image-mention-slug.js"
|
|
15
|
+
import type { ConnectedReference } from "../types.js"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Wired-entity mentions — `@<name-slug>:<index>[:<role>]` for `wired-creature`
|
|
19
|
+
* and `wired-object`.
|
|
20
|
+
*
|
|
21
|
+
* Mirrors `image-mention-slug.test.ts` shape-for-shape, because the two speak
|
|
22
|
+
* the SAME grammar through `mention-token-grammar.ts`. The load-bearing
|
|
23
|
+
* assertions are the ones that keep this parser from colliding with the other
|
|
24
|
+
* grammars (a 4-part token is never claimed; a location bucket token is never
|
|
25
|
+
* spliced) and the ones that pin the ref gate: only creatures/objects, never an
|
|
26
|
+
* extra, and grammar-invalid slugs dropped even when non-empty.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const creature = (over: Partial<ConnectedReference> = {}): ConnectedReference => ({
|
|
30
|
+
id: "cr-1",
|
|
31
|
+
defaultName: "Nessie",
|
|
32
|
+
source: "wired-creature",
|
|
33
|
+
url: "https://cdn/nessie.png",
|
|
34
|
+
...over,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const object_ = (over: Partial<ConnectedReference> = {}): ConnectedReference => ({
|
|
38
|
+
id: "ob-1",
|
|
39
|
+
defaultName: "Chair",
|
|
40
|
+
source: "wired-object",
|
|
41
|
+
url: "https://cdn/chair.png",
|
|
42
|
+
...over,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe("entityMentionSlug", () => {
|
|
46
|
+
it("lowercases and dash-joins", () => {
|
|
47
|
+
expect(entityMentionSlug("Nessie")).toBe("nessie")
|
|
48
|
+
expect(entityMentionSlug("Deep Lake Serpent")).toBe("deep-lake-serpent")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("collapses punctuation runs and strips leading/trailing dashes", () => {
|
|
52
|
+
expect(entityMentionSlug(" Nessie -- Beast!! ")).toBe("nessie-beast")
|
|
53
|
+
expect(entityMentionSlug("--chair--")).toBe("chair")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("keeps digits, including a leading one (emptiness is NOT the grammar gate)", () => {
|
|
57
|
+
// Non-empty yet UNPARSEABLE — the leading digit fails MENTION_SLUG_PATTERN.
|
|
58
|
+
// `knownEntitySlugsFromRefs` is what must drop it, not this function.
|
|
59
|
+
expect(entityMentionSlug("3-Eyed Raven")).toBe("3-eyed-raven")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("returns empty for a name with no latin alphanumerics", () => {
|
|
63
|
+
expect(entityMentionSlug("דרקון")).toBe("")
|
|
64
|
+
expect(entityMentionSlug("🐉🐉")).toBe("")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("is byte-identical to the image / character slug algorithm", () => {
|
|
68
|
+
for (const name of ["Nessie", "Deep Lake Serpent", "A B", "Ünï-cödé 12"]) {
|
|
69
|
+
expect(entityMentionSlug(name)).toBe(imageMentionSlug(name))
|
|
70
|
+
expect(entityMentionSlug(name)).toBe(
|
|
71
|
+
name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""),
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe("parseEntityMentionToken — rejects", () => {
|
|
78
|
+
it("rejects text that is not a token at all", () => {
|
|
79
|
+
expect(parseEntityMentionToken("nessie:1")).toBeNull() // no @
|
|
80
|
+
expect(parseEntityMentionToken("@")).toBeNull()
|
|
81
|
+
expect(parseEntityMentionToken("@nessie")).toBeNull() // no index segment
|
|
82
|
+
expect(parseEntityMentionToken("@nessie:")).toBeNull() // empty index
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("rejects a non-positive or non-numeric index", () => {
|
|
86
|
+
expect(parseEntityMentionToken("@nessie:0")).toBeNull()
|
|
87
|
+
expect(parseEntityMentionToken("@nessie:x")).toBeNull()
|
|
88
|
+
expect(parseEntityMentionToken("@nessie:1x")).toBeNull()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("rejects out-of-grammar slugs", () => {
|
|
92
|
+
expect(parseEntityMentionToken("@Nessie:1")).toBeNull() // uppercase
|
|
93
|
+
expect(parseEntityMentionToken("@3d:1")).toBeNull() // leading digit
|
|
94
|
+
expect(parseEntityMentionToken("@-nessie:1")).toBeNull() // leading dash
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("rejects a FOUR-part token — the collision guard against character mentions", () => {
|
|
98
|
+
expect(parseEntityMentionToken("@nessie:1:a:b")).toBeNull()
|
|
99
|
+
expect(parseEntityMentionToken("@kira:1:smile:face")).toBeNull()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("rejects an empty or out-of-grammar role segment", () => {
|
|
103
|
+
expect(parseEntityMentionToken("@nessie:1:")).toBeNull()
|
|
104
|
+
expect(parseEntityMentionToken("@nessie:1:Bad")).toBeNull()
|
|
105
|
+
expect(parseEntityMentionToken("@nessie:1:1st")).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe("parseEntityMentionToken — accepts", () => {
|
|
110
|
+
it("parses a bare 2-part token and emits NO role key", () => {
|
|
111
|
+
const parsed = parseEntityMentionToken("@nessie:4")
|
|
112
|
+
expect(parsed).toEqual({ entitySlug: "nessie", entityIndex: 4 })
|
|
113
|
+
// Shape rule: a 2-part token must stay shape-identical to a role-less parser.
|
|
114
|
+
expect(parsed && "role" in parsed).toBe(false)
|
|
115
|
+
expect(parsed && "lock" in parsed).toBe(false)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("parses a curated CREATURE role in the 3rd segment", () => {
|
|
119
|
+
expect(parseEntityMentionToken("@nessie:4:markings")).toEqual({
|
|
120
|
+
entitySlug: "nessie",
|
|
121
|
+
entityIndex: 4,
|
|
122
|
+
role: "markings",
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("parses a curated OBJECT role in the 3rd segment", () => {
|
|
127
|
+
expect(parseEntityMentionToken("@chair:2:material")).toEqual({
|
|
128
|
+
entitySlug: "chair",
|
|
129
|
+
entityIndex: 2,
|
|
130
|
+
role: "material",
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("passes a CUSTOM role through verbatim", () => {
|
|
135
|
+
expect(parseEntityMentionToken("@nessie:4:my-custom-role")).toEqual({
|
|
136
|
+
entitySlug: "nessie",
|
|
137
|
+
entityIndex: 4,
|
|
138
|
+
role: "my-custom-role",
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("parses the ~lock / ~nolock sentinels as a tri-state", () => {
|
|
143
|
+
expect(parseEntityMentionToken("@nessie:4~lock")).toEqual({
|
|
144
|
+
entitySlug: "nessie", entityIndex: 4, lock: true,
|
|
145
|
+
})
|
|
146
|
+
expect(parseEntityMentionToken("@nessie:4~nolock")).toEqual({
|
|
147
|
+
entitySlug: "nessie", entityIndex: 4, lock: false,
|
|
148
|
+
})
|
|
149
|
+
expect(parseEntityMentionToken("@nessie:4:markings~nolock")).toEqual({
|
|
150
|
+
entitySlug: "nessie", entityIndex: 4, role: "markings", lock: false,
|
|
151
|
+
})
|
|
152
|
+
// The role + force-ON pairing — the other half of the role×sentinel matrix.
|
|
153
|
+
expect(parseEntityMentionToken("@nessie:4:markings~lock")).toEqual({
|
|
154
|
+
entitySlug: "nessie", entityIndex: 4, role: "markings", lock: true,
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("accepts a multi-segment slug and a large index", () => {
|
|
159
|
+
expect(parseEntityMentionToken("@deep-lake-serpent:12")).toEqual({
|
|
160
|
+
entitySlug: "deep-lake-serpent",
|
|
161
|
+
entityIndex: 12,
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("agrees segment-for-segment with the image parser (one shared grammar)", () => {
|
|
166
|
+
for (const t of [
|
|
167
|
+
"@nessie:4", "@nessie:4:markings", "@nessie:4~nolock", "@nessie:4:markings~lock",
|
|
168
|
+
"@x:1:a:b", "@3d:1", "@nessie:0", "not-a-token",
|
|
169
|
+
]) {
|
|
170
|
+
const e = parseEntityMentionToken(t)
|
|
171
|
+
const i = parseImageMentionToken(t)
|
|
172
|
+
expect(e === null).toBe(i === null)
|
|
173
|
+
if (!e || !i) continue
|
|
174
|
+
expect(e.entitySlug).toBe(i.imageSlug)
|
|
175
|
+
expect(e.entityIndex).toBe(i.imageIndex)
|
|
176
|
+
expect("role" in e).toBe("role" in i)
|
|
177
|
+
expect(e.role).toBe(i.role)
|
|
178
|
+
expect("lock" in e).toBe("lock" in i)
|
|
179
|
+
expect(e.lock).toBe(i.lock)
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe("findEntityMentionTokens", () => {
|
|
185
|
+
it("finds a known slug and reports its exact offset", () => {
|
|
186
|
+
const prompt = "a wide shot of @nessie:4 rising from the lake"
|
|
187
|
+
const [t] = findEntityMentionTokens(prompt, ["nessie"])
|
|
188
|
+
expect(t.token).toBe("@nessie:4")
|
|
189
|
+
expect(t.entitySlug).toBe("nessie")
|
|
190
|
+
expect(t.entityIndex).toBe(4)
|
|
191
|
+
expect(prompt.slice(t.offset, t.offset + t.token.length)).toBe("@nessie:4")
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("matches a token at the very start of the prompt", () => {
|
|
195
|
+
const [t] = findEntityMentionTokens("@nessie:1 rises", ["nessie"])
|
|
196
|
+
expect(t.offset).toBe(0)
|
|
197
|
+
expect(t.token).toBe("@nessie:1")
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it("filters out slugs that are not known", () => {
|
|
201
|
+
expect(findEntityMentionTokens("a shot of @nessie:4", [])).toEqual([])
|
|
202
|
+
expect(findEntityMentionTokens("a shot of @nessie:4", ["chair"])).toEqual([])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it("does not match an email-like `a@nessie:1` (preceding alphanumeric)", () => {
|
|
206
|
+
expect(findEntityMentionTokens("mail a@nessie:1 now", ["nessie"])).toEqual([])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("yields NO token for a 4-part CHARACTER token, even when the slug is a known entity", () => {
|
|
210
|
+
// The `(?![:a-z0-9-])` lookahead: without it this would be captured as the
|
|
211
|
+
// 3-part `@kira:1:smile`, leaving `:face` dangling in the prompt.
|
|
212
|
+
expect(findEntityMentionTokens("@kira:1:smile:face poses", ["kira"])).toEqual([])
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it("does not swallow `~locked` as a sentinel", () => {
|
|
216
|
+
const [t] = findEntityMentionTokens("@nessie:1~locked", ["nessie"])
|
|
217
|
+
expect(t.token).toBe("@nessie:1")
|
|
218
|
+
expect("lock" in t).toBe(false)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it("does claim the sentinel when it is well-formed", () => {
|
|
222
|
+
const [t] = findEntityMentionTokens("@nessie:1~lock", ["nessie"])
|
|
223
|
+
expect(t.token).toBe("@nessie:1~lock")
|
|
224
|
+
expect(t.lock).toBe(true)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it("claims a `~nolock` sentinel through the FINDER too (force-OFF, tri-state)", () => {
|
|
228
|
+
const [t] = findEntityMentionTokens("a shot of @nessie:1~nolock at dusk", ["nessie"])
|
|
229
|
+
expect(t.token).toBe("@nessie:1~nolock")
|
|
230
|
+
expect(t.lock).toBe(false)
|
|
231
|
+
expect(findEntityMentionTokens("@nessie:1~nolockx", ["nessie"])[0].token).toBe("@nessie:1")
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it("yields NO token for a LOCATION bucket/variant token, even on a known slug", () => {
|
|
235
|
+
// The slash guard. Without it the finder claims the truncated 3-part prefix
|
|
236
|
+
// `@old-library:1:weather` and SPLICES it, leaving `/rain` dangling in the
|
|
237
|
+
// model-facing prompt.
|
|
238
|
+
expect(findEntityMentionTokens("a shot of @old-library:1:weather/rain", ["old-library"]))
|
|
239
|
+
.toEqual([])
|
|
240
|
+
// 4-part location token (variant + mode) — same rejection.
|
|
241
|
+
expect(findEntityMentionTokens("@old-library:1:weather/rain:style", ["old-library"]))
|
|
242
|
+
.toEqual([])
|
|
243
|
+
// Sentinel + slash: the whole token stays literal rather than backtracking
|
|
244
|
+
// to `@nessie:1` and splicing that.
|
|
245
|
+
expect(findEntityMentionTokens("@nessie:1~lock/rain", ["nessie"])).toEqual([])
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it("still matches two mentions separated by a slash (`/` alone is not the signal)", () => {
|
|
249
|
+
const tokens = findEntityMentionTokens("@nessie:1/@chair:2", ["nessie", "chair"])
|
|
250
|
+
expect(tokens.map((t) => t.token)).toEqual(["@nessie:1", "@chair:2"])
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it("finds several mentions in prompt order", () => {
|
|
254
|
+
const tokens = findEntityMentionTokens("@nessie:1 then @chair:2:material", ["nessie", "chair"])
|
|
255
|
+
expect(tokens.map((t) => t.token)).toEqual(["@nessie:1", "@chair:2:material"])
|
|
256
|
+
expect(tokens[1].role).toBe("material")
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it("finds the same token SURFACE as the image finder (one shared finder)", () => {
|
|
260
|
+
for (const prompt of [
|
|
261
|
+
"a shot of @nessie:4 at dusk",
|
|
262
|
+
"@kira:1:smile:face poses",
|
|
263
|
+
"@nessie:1~locked",
|
|
264
|
+
"@old-library:1:weather/rain",
|
|
265
|
+
"@nessie:1/@chair:2",
|
|
266
|
+
]) {
|
|
267
|
+
const known = ["nessie", "kira", "old-library", "chair"]
|
|
268
|
+
expect(findEntityMentionTokens(prompt, known).map((t) => [t.token, t.offset]))
|
|
269
|
+
.toEqual(findImageMentionTokens(prompt, known).map((t) => [t.token, t.offset]))
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
describe("knownEntitySlugsFromRefs", () => {
|
|
275
|
+
it("includes wired-creature and wired-object refs", () => {
|
|
276
|
+
expect(knownEntitySlugsFromRefs([creature(), object_()])).toEqual(["nessie", "chair"])
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it("excludes every non-entity source", () => {
|
|
280
|
+
expect(
|
|
281
|
+
knownEntitySlugsFromRefs([
|
|
282
|
+
creature({ source: "wired-character", defaultName: "Kira" }),
|
|
283
|
+
creature({ source: "wired-location", defaultName: "Old Library" }),
|
|
284
|
+
creature({ source: "wired-image", defaultName: "Town" }),
|
|
285
|
+
creature({ source: "manual", defaultName: "My Upload" }),
|
|
286
|
+
creature({ source: "wired-face", defaultName: "Face" }),
|
|
287
|
+
]),
|
|
288
|
+
).toEqual([])
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it("excludes extra refs (they render through the extras path)", () => {
|
|
292
|
+
expect(knownEntitySlugsFromRefs([creature({ isExtraRef: true })])).toEqual([])
|
|
293
|
+
expect(knownEntitySlugsFromRefs([object_({ isExtraRef: true })])).toEqual([])
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it("excludes url-less and name-less refs", () => {
|
|
297
|
+
expect(knownEntitySlugsFromRefs([creature({ url: "" })])).toEqual([])
|
|
298
|
+
expect(knownEntitySlugsFromRefs([creature({ defaultName: "" })])).toEqual([])
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it("drops a grammar-invalid slug even though it is non-empty", () => {
|
|
302
|
+
expect(knownEntitySlugsFromRefs([creature({ defaultName: "3-Eyed Raven" })])).toEqual([])
|
|
303
|
+
expect(knownEntitySlugsFromRefs([creature({ defaultName: "🐉" })])).toEqual([])
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it("dedupes refs that slug to the same name", () => {
|
|
307
|
+
expect(
|
|
308
|
+
knownEntitySlugsFromRefs([
|
|
309
|
+
creature({ id: "a", defaultName: "Lake Beast", url: "https://cdn/a.png" }),
|
|
310
|
+
object_({ id: "b", defaultName: "Lake Beast", url: "https://cdn/b.png" }),
|
|
311
|
+
]),
|
|
312
|
+
).toEqual(["lake-beast"])
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it("is UNFILTERED — a slug also claimed by a character still appears here", () => {
|
|
316
|
+
// Cross-kind precedence is the RESOLVER's pass order, not this set's job.
|
|
317
|
+
// Subtracting the character's slug here would put the rule in two places.
|
|
318
|
+
const refs: ConnectedReference[] = [
|
|
319
|
+
{ id: "c1", defaultName: "Nessie", source: "wired-character", url: "https://cdn/k.png", characterSlug: "nessie" },
|
|
320
|
+
creature(),
|
|
321
|
+
]
|
|
322
|
+
expect(knownEntitySlugsFromRefs(refs)).toEqual(["nessie"])
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it("is the exact gate the finder uses (no slug in the set is unmatchable)", () => {
|
|
326
|
+
const refs = [creature(), creature({ id: "b", defaultName: "3-Eyed Raven", url: "https://cdn/b.png" })]
|
|
327
|
+
const slugs = knownEntitySlugsFromRefs(refs)
|
|
328
|
+
for (const slug of slugs) {
|
|
329
|
+
expect(findEntityMentionTokens(`@${slug}:1`, slugs)).toHaveLength(1)
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
describe("entityMentionSlugForRef — the single mentionability gate", () => {
|
|
335
|
+
it("returns the slug for a mentionable creature / object ref", () => {
|
|
336
|
+
expect(entityMentionSlugForRef(creature())).toBe("nessie")
|
|
337
|
+
expect(entityMentionSlugForRef(object_({ defaultName: "Wooden Chair" }))).toBe("wooden-chair")
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it("returns null for every non-mentionable ref", () => {
|
|
341
|
+
expect(entityMentionSlugForRef(creature({ source: "wired-character" }))).toBeNull()
|
|
342
|
+
expect(entityMentionSlugForRef(creature({ source: "wired-image" }))).toBeNull()
|
|
343
|
+
expect(entityMentionSlugForRef(creature({ isExtraRef: true }))).toBeNull()
|
|
344
|
+
expect(entityMentionSlugForRef(creature({ url: "" }))).toBeNull()
|
|
345
|
+
expect(entityMentionSlugForRef(creature({ defaultName: "" }))).toBeNull()
|
|
346
|
+
// Non-empty but grammar-invalid — emptiness is NOT the gate.
|
|
347
|
+
expect(entityMentionSlugForRef(creature({ defaultName: "3-Eyed Raven" }))).toBeNull()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it("is DISJOINT from the image gate — no ref can be claimed by both kinds", () => {
|
|
351
|
+
// The two gates partition by `source`, so a ref never has two mention
|
|
352
|
+
// grammars competing for it and the pass order only has to settle
|
|
353
|
+
// NAME collisions between distinct refs.
|
|
354
|
+
const refs: ConnectedReference[] = [
|
|
355
|
+
creature(),
|
|
356
|
+
object_(),
|
|
357
|
+
{ id: "i", defaultName: "Town", source: "wired-image", url: "https://cdn/i.png" },
|
|
358
|
+
{ id: "m", defaultName: "My Upload", source: "manual", url: "https://cdn/m.png" },
|
|
359
|
+
{ id: "k", defaultName: "Kira", source: "wired-character", url: "https://cdn/k.png" },
|
|
360
|
+
]
|
|
361
|
+
for (const r of refs) {
|
|
362
|
+
const claims = [entityMentionSlugForRef(r), imageMentionSlugForRef(r)]
|
|
363
|
+
.filter((s) => s !== null)
|
|
364
|
+
expect(claims.length).toBeLessThanOrEqual(1)
|
|
365
|
+
}
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it("is the SAME gate `knownEntitySlugsFromRefs` applies (the two views cannot drift)", () => {
|
|
369
|
+
const refs = [
|
|
370
|
+
creature(),
|
|
371
|
+
creature({ id: "b", defaultName: "3-Eyed Raven", url: "https://cdn/b.png" }),
|
|
372
|
+
creature({ id: "c", defaultName: "Extra", isExtraRef: true, url: "https://cdn/c.png" }),
|
|
373
|
+
object_({ id: "d" }),
|
|
374
|
+
{ id: "e", defaultName: "Town", source: "wired-image" as const, url: "https://cdn/e.png" },
|
|
375
|
+
]
|
|
376
|
+
expect(knownEntitySlugsFromRefs(refs)).toEqual(
|
|
377
|
+
[...new Set(refs.map(entityMentionSlugForRef).filter((s): s is string => s !== null))],
|
|
378
|
+
)
|
|
379
|
+
})
|
|
380
|
+
})
|