@nodaro/shared 2.19.0 → 2.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "2.19.0",
3
+ "version": "2.20.0",
4
4
  "description": "Shared types, model catalog, wire contracts, and structural vocabularies for the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  buildCreditModelIdentifier,
3
+ resolveImageGenCreditIdentifier,
4
+ resolveNormalizedImageGen,
3
5
  buildVideoCreditModelIdentifier,
4
6
  buildMotionCreditModelIdentifier,
5
7
  } from "../credit-identifiers.js"
@@ -870,3 +872,134 @@ describe("gemini-omni-flash", () => {
870
872
  }
871
873
  })
872
874
  })
875
+
876
+ // ---------------------------------------------------------------------------
877
+ // resolveNormalizedImageGen
878
+ // ---------------------------------------------------------------------------
879
+ describe("resolveNormalizedImageGen", () => {
880
+ it("snaps an off-list aspect ratio and reports it", () => {
881
+ const out = resolveNormalizedImageGen({
882
+ provider: "gpt-image-2",
883
+ aspectRatio: "3:2",
884
+ resolution: "4K",
885
+ refCount: 0,
886
+ swapToI2i: true,
887
+ })
888
+ // 3:2 is not in GPT_IMAGE_2_RATIOS -> allowed[0] ("auto"), which then
889
+ // cascades resolution to 1K via the model's cross-field rule.
890
+ expect(out.aspectRatio).toBe("auto")
891
+ expect(out.resolution).toBe("1K")
892
+ expect(out.identifier).toBe("gpt-image-2")
893
+ expect(out.adjustments.map((a) => a.field)).toEqual(["aspectRatio", "resolution"])
894
+ })
895
+
896
+ it("prices the SNAPPED resolution — gpt-image-2 auto + 2K reserves the 1K tier", () => {
897
+ const out = resolveNormalizedImageGen({
898
+ provider: "gpt-image-2",
899
+ aspectRatio: "auto",
900
+ resolution: "2K",
901
+ refCount: 0,
902
+ swapToI2i: true,
903
+ })
904
+ expect(out.resolution).toBe("1K")
905
+ expect(out.identifier).toBe("gpt-image-2")
906
+ })
907
+
908
+ it("drops a lever the model does not have, and reports it", () => {
909
+ const out = resolveNormalizedImageGen({
910
+ provider: "recraft-upscale",
911
+ aspectRatio: "16:9",
912
+ refCount: 0,
913
+ })
914
+ expect(out.aspectRatio).toBeUndefined()
915
+ expect(out.adjustments).toHaveLength(1)
916
+ expect(out.adjustments[0].field).toBe("aspectRatio")
917
+ expect(out.adjustments[0].to).toBeUndefined()
918
+ })
919
+
920
+ // grok-i2i deliberately has NO `aspectRatios` on its catalog entry, and that
921
+ // is the HONEST declaration: KIE's grok-imagine/image-to-image schema accepts
922
+ // only prompt / image_urls / nsfw_checker — there is no aspect_ratio param
923
+ // (https://docs.kie.ai/market/grok-imagine/image-to-image.md). That is why
924
+ // kie/models.ts gives it `extraParams: {}` while its t2i sibling gets
925
+ // `{ aspect_ratio: "16:9" }`, and why kie/CLAUDE.md lists grok-i2i under
926
+ // "No aspect ratio control". Declaring the t2i ratios here to "preserve" a
927
+ // caller's value would put a lie in the catalog and keep forwarding a param
928
+ // the endpoint ignores; dropping it and disclosing that via `adjustments` is
929
+ // the correct behaviour. Do not "fix" this by adding GROK_RATIOS.
930
+ it("drops the aspect ratio when grok auto-swaps to its ratio-less i2i sibling", () => {
931
+ const out = resolveNormalizedImageGen({
932
+ provider: "grok",
933
+ aspectRatio: "16:9",
934
+ refCount: 1,
935
+ swapToI2i: true,
936
+ })
937
+ expect(out.modelId).toBe("grok-i2i")
938
+ expect(out.aspectRatio).toBeUndefined()
939
+ expect(out.adjustments).toHaveLength(1)
940
+ expect(out.adjustments[0].field).toBe("aspectRatio")
941
+ expect(out.adjustments[0].from).toBe("16:9")
942
+ expect(out.adjustments[0].to).toBeUndefined()
943
+ expect(out.identifier).toBe("grok-i2i")
944
+ })
945
+
946
+ it("snaps against the i2i variant when the T2I provider auto-swaps", () => {
947
+ const out = resolveNormalizedImageGen({
948
+ provider: "gpt-image-2",
949
+ aspectRatio: "3:4",
950
+ refCount: 2,
951
+ swapToI2i: true,
952
+ })
953
+ expect(out.modelId).toBe("gpt-image-2-i2i")
954
+ expect(out.aspectRatio).toBe("3:4")
955
+ expect(out.adjustments).toEqual([])
956
+ })
957
+
958
+ it("coerces non-string levers to undefined (the preHandler runs pre-Zod)", () => {
959
+ const out = resolveNormalizedImageGen({
960
+ provider: "gpt-image-2",
961
+ aspectRatio: 169,
962
+ resolution: null,
963
+ quality: {},
964
+ refCount: 0,
965
+ })
966
+ expect(out.aspectRatio).toBeUndefined()
967
+ expect(out.resolution).toBeUndefined()
968
+ expect(out.quality).toBeUndefined()
969
+ expect(out.adjustments).toEqual([])
970
+ expect(out.identifier).toBe("gpt-image-2")
971
+ })
972
+
973
+ it("passes an unknown model id through untouched", () => {
974
+ const out = resolveNormalizedImageGen({
975
+ provider: "not-a-model",
976
+ aspectRatio: "13:7",
977
+ resolution: "9K",
978
+ refCount: 0,
979
+ })
980
+ expect(out.aspectRatio).toBe("13:7")
981
+ expect(out.resolution).toBe("9K")
982
+ expect(out.adjustments).toEqual([])
983
+ expect(out.identifier).toBe("not-a-model")
984
+ })
985
+ })
986
+
987
+ // Regression pins: identifiers that are correct TODAY must be byte-identical
988
+ // once resolveImageGenCreditIdentifier delegates through the normalizer.
989
+ describe("resolveImageGenCreditIdentifier is unchanged for already-valid input", () => {
990
+ it.each([
991
+ [{ provider: "flux-2-max", refCount: 3, swapToI2i: true }, "flux-2-max:1MP:3ref"],
992
+ [{ provider: "flux-2-max", resolution: "4 MP", refCount: 8, swapToI2i: true }, "flux-2-max:4MP:8ref"],
993
+ [{ provider: "flux-2-pro", refCount: 0, swapToI2i: true }, "flux-2-pro:1MP:0ref"],
994
+ [{ provider: "seedream-5-lite", refCount: 1, swapToI2i: true }, "seedream-5-lite-i2i"],
995
+ [{ provider: "seedream", quality: "high", refCount: 0, swapToI2i: true }, "seedream:high"],
996
+ [{ provider: "gpt-image", quality: "high", refCount: 0, swapToI2i: true }, "gpt-image:high"],
997
+ [{ provider: "flux", resolution: "2K", refCount: 0, swapToI2i: true }, "flux:2K"],
998
+ [{ provider: "nano-banana-pro", resolution: "4K", refCount: 0, swapToI2i: true }, "nano-banana-pro:4K"],
999
+ [{ provider: "gpt-image-2", resolution: "4K", aspectRatio: "16:9", refCount: 0, swapToI2i: true }, "gpt-image-2:4K"],
1000
+ [{ provider: "ideogram-v3", renderingSpeed: "TURBO", refCount: 0, swapToI2i: true }, "ideogram-v3:TURBO"],
1001
+ [{ provider: undefined, refCount: 0, swapToI2i: true }, "nano-banana"],
1002
+ ] as const)("%o -> %s", (opts, expected) => {
1003
+ expect(resolveImageGenCreditIdentifier({ ...opts } as Parameters<typeof resolveImageGenCreditIdentifier>[0])).toBe(expected)
1004
+ })
1005
+ })
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ MODEL_CATALOG,
4
+ normalizeModelInput,
5
+ } from "../model-catalog.js"
6
+ import {
7
+ HIGH_QUALITY_PROVIDERS,
8
+ IMAGE_ASPECT_RATIO_VALUES,
9
+ TWO_K_RESOLUTION_PROVIDERS,
10
+ RESOLUTION_2K_4K_TIERED_PROVIDERS,
11
+ } from "../model-constants.js"
12
+ import { FLUX2_RES_MP, isFlux2Model } from "../flux2-pricing.js"
13
+
14
+ /**
15
+ * `resolveNormalizedImageGen` prices the credit identifier off the SNAPPED
16
+ * lever values (see credit-identifiers.ts). That is only safe while every
17
+ * value a pricing set keys on is also DECLARED on that model's catalog entry:
18
+ * a priced value the catalog omits would be snapped away or rewritten, and the
19
+ * reserve would silently move to a different tier.
20
+ *
21
+ * A model that is priced on a lever but ABSENT from MODEL_CATALOG is fine —
22
+ * `normalizeModelInput` passes unknown ids through untouched.
23
+ */
24
+ describe("composite image pricing ⊆ catalog-declared levers", () => {
25
+ it("every HIGH_QUALITY_PROVIDERS member in the catalog declares quality \"high\"", () => {
26
+ let checked = 0
27
+ for (const id of HIGH_QUALITY_PROVIDERS) {
28
+ const entry = MODEL_CATALOG[id]
29
+ if (!entry) continue
30
+ checked++
31
+ expect(entry.qualities, `${id}.qualities`).toBeDefined()
32
+ expect(entry.qualities, `${id}.qualities`).toContain("high")
33
+ }
34
+ expect(checked).toBeGreaterThan(0)
35
+ })
36
+
37
+ it("every TWO_K_RESOLUTION_PROVIDERS member in the catalog declares \"2K\"", () => {
38
+ let checked = 0
39
+ for (const id of TWO_K_RESOLUTION_PROVIDERS) {
40
+ const entry = MODEL_CATALOG[id]
41
+ if (!entry) continue
42
+ checked++
43
+ expect(entry.resolutions, `${id}.resolutions`).toContain("2K")
44
+ }
45
+ expect(checked).toBeGreaterThan(0)
46
+ })
47
+
48
+ it("every RESOLUTION_2K_4K_TIERED_PROVIDERS member in the catalog declares \"2K\" and \"4K\"", () => {
49
+ let checked = 0
50
+ for (const id of RESOLUTION_2K_4K_TIERED_PROVIDERS) {
51
+ const entry = MODEL_CATALOG[id]
52
+ if (!entry) continue
53
+ checked++
54
+ expect(entry.resolutions, `${id}.resolutions`).toContain("2K")
55
+ expect(entry.resolutions, `${id}.resolutions`).toContain("4K")
56
+ }
57
+ expect(checked).toBeGreaterThan(0)
58
+ })
59
+
60
+ it("nano-banana-pro declares \"4K\" (its own pricing branch)", () => {
61
+ expect(MODEL_CATALOG["nano-banana-pro"]?.resolutions).toContain("4K")
62
+ })
63
+
64
+ // Flux 2 stores a BARE megapixel count ("1") while the catalog lists "1 MP".
65
+ // normalizeModelInput canonicalizes rather than "corrects" that spelling —
66
+ // if it ever stopped doing so, every Flux 2 node priced from stored data
67
+ // would jump to defaultResolutionFor()'s tier.
68
+ it("Flux 2 bare-MP values canonicalize to the catalog spelling, not a different tier", () => {
69
+ let checked = 0
70
+ for (const id of Object.keys(MODEL_CATALOG)) {
71
+ if (!isFlux2Model(id)) continue
72
+ checked++
73
+ for (const mp of FLUX2_RES_MP) {
74
+ const out = normalizeModelInput(id, { resolution: mp })
75
+ expect(out.resolution, `${id} + "${mp}"`).toBe(`${mp} MP`)
76
+ expect(out.adjustments, `${id} + "${mp}" must not report a correction`).toEqual([])
77
+ }
78
+ }
79
+ expect(checked).toBeGreaterThan(0)
80
+ })
81
+
82
+ // Declared values are always identity under the snap. Cheap, and it fails
83
+ // loudly if someone adds a duplicate/typo'd entry to a lever list.
84
+ it("every declared image lever value normalizes to itself", () => {
85
+ for (const [id, entry] of Object.entries(MODEL_CATALOG)) {
86
+ if (entry.kind !== "image") continue
87
+ for (const a of entry.aspectRatios ?? []) {
88
+ expect(normalizeModelInput(id, { aspectRatio: a }).aspectRatio, `${id} ratio ${a}`).toBe(a)
89
+ }
90
+ for (const r of entry.resolutions ?? []) {
91
+ expect(normalizeModelInput(id, { resolution: r }).resolution, `${id} res ${r}`).toBe(r)
92
+ }
93
+ for (const q of entry.qualities ?? []) {
94
+ expect(normalizeModelInput(id, { quality: q }).quality, `${id} quality ${q}`).toBe(q)
95
+ }
96
+ }
97
+ })
98
+ })
99
+
100
+ /**
101
+ * `IMAGE_ASPECT_RATIO_VALUES` is the ONE ratio vocabulary the three image
102
+ * routes' Zod enums are built from (`/v1/generate-image`, `/v1/image-to-image`,
103
+ * `/v1/edit-image`). Two invariants make that safe, and both are asserted here
104
+ * rather than left to review:
105
+ *
106
+ * 1. It must be a SUPERSET of every ratio any image model declares. A catalog
107
+ * ratio the tuple omits is a live 400 on a value the picker offers — the
108
+ * exact bug that shipped twice (Wan 2.7's `8:1`/`1:8`, then Nano Banana 2
109
+ * Lite's `4:1`/`1:4`). It also makes `applySnappedLevers`' "enum can't carry
110
+ * the snapped value" fallback unreachable: the snap can only ever return a
111
+ * value the caller sent, its canonical spelling, or a catalog-declared
112
+ * option — all three are in the tuple by this test.
113
+ * 2. No duplicates. `z.enum` de-dupes silently, so a copy-paste repeat would
114
+ * never surface at runtime.
115
+ *
116
+ * Widening the tuple is always safe: the per-model gate is the catalog snap,
117
+ * which CORRECTS an unsupported ratio (and discloses it) instead of rejecting.
118
+ */
119
+ describe("IMAGE_ASPECT_RATIO_VALUES covers every catalog-declared image ratio", () => {
120
+ it("is a superset of every `kind: \"image\"` entry's aspectRatios", () => {
121
+ const allowed = new Set<string>(IMAGE_ASPECT_RATIO_VALUES)
122
+ const missing: string[] = []
123
+ let checked = 0
124
+ for (const [id, entry] of Object.entries(MODEL_CATALOG)) {
125
+ if (entry.kind !== "image") continue
126
+ for (const r of entry.aspectRatios ?? []) {
127
+ checked++
128
+ if (!allowed.has(r)) missing.push(`${id} declares "${r}"`)
129
+ }
130
+ }
131
+ expect(missing).toEqual([])
132
+ // Guard the guard: an empty catalog sweep would make this vacuous.
133
+ expect(checked).toBeGreaterThan(100)
134
+ })
135
+
136
+ it("has no duplicate entries (z.enum would swallow them)", () => {
137
+ expect(new Set<string>(IMAGE_ASPECT_RATIO_VALUES).size).toBe(IMAGE_ASPECT_RATIO_VALUES.length)
138
+ })
139
+ })
@@ -59,6 +59,42 @@ describe("normalizeNodeModelParams", () => {
59
59
  expect(MODEL_PARAM_NODE_TYPES.has("image-to-video")).toBe(false)
60
60
  })
61
61
 
62
+ // The write boundary has to cover every image node an agent can author, not
63
+ // just the two that shipped first: `modify-image` carries the same
64
+ // provider/aspectRatio/resolution/quality trio, and `edit-image` carries
65
+ // provider + aspectRatio (its `targetResolution` is an upscale target, a
66
+ // DIFFERENT field the normalizer never reads, so there is no conflation).
67
+ it("covers every image node type whose data carries catalog-governed params", () => {
68
+ expect([...MODEL_PARAM_NODE_TYPES].sort()).toEqual([
69
+ "edit-image",
70
+ "generate-image",
71
+ "image-to-image",
72
+ "modify-image",
73
+ ])
74
+ })
75
+
76
+ it("heals a modify-image node written straight into workflow JSON", () => {
77
+ const { nodes, adjustments } = normalizeNodeModelParams([
78
+ node("m9", "modify-image", { provider: "gpt-image", aspectRatio: "16:9", quality: "basic" }),
79
+ ])
80
+ const d = nodes[0].data as Record<string, unknown>
81
+ expect(d.aspectRatio).not.toBe("16:9")
82
+ expect(d.quality).toBe("medium") // gpt-image declares ["medium", "high"]
83
+ expect(adjustments.map((a) => a.field).sort()).toEqual(["aspectRatio", "quality"])
84
+ expect(adjustments[0].nodeId).toBe("m9")
85
+ })
86
+
87
+ it("heals an edit-image node's ratio without touching its targetResolution", () => {
88
+ const { nodes, adjustments } = normalizeNodeModelParams([
89
+ node("e1", "edit-image", { provider: "recraft-upscale", aspectRatio: "16:9", targetResolution: "4K" }),
90
+ ])
91
+ const d = nodes[0].data as Record<string, unknown>
92
+ // The upscalers declare no aspectRatios at all -> the lever is dropped.
93
+ expect(d.aspectRatio).toBeUndefined()
94
+ expect(d.targetResolution).toBe("4K")
95
+ expect(adjustments.map((a) => a.field)).toEqual(["aspectRatio"])
96
+ })
97
+
62
98
  it("skips multi-provider nodes rather than guessing an intersection", () => {
63
99
  const input = [
64
100
  node("m1", "generate-image", {
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from "node:fs"
1
2
  import { describe, expect, it } from "vitest"
2
3
  import {
3
4
  ACCESS_LEVELS,
@@ -9,13 +10,14 @@ import {
9
10
  OrgSettingsSchema,
10
11
  PRESET_SETTING_KEYS,
11
12
  PresetSettingsSchema,
13
+ USAGE_GROUP_BYS,
12
14
  WORKSPACE_ROLES,
13
15
  WorkspaceSettingsSchema,
14
16
  } from "../organizations/index.js"
15
17
 
16
18
  describe("organizations wire contract — enums", () => {
17
19
  it("every enum is non-empty and duplicate-free", () => {
18
- for (const list of [ORG_KINDS, ORG_ROLES, WORKSPACE_ROLES, MEMBER_STATUSES, COLLABORATOR_ROLES, ACCESS_LEVELS, ORG_ERROR_CODES]) {
20
+ for (const list of [ORG_KINDS, ORG_ROLES, WORKSPACE_ROLES, MEMBER_STATUSES, COLLABORATOR_ROLES, ACCESS_LEVELS, ORG_ERROR_CODES, USAGE_GROUP_BYS]) {
19
21
  expect(list.length).toBeGreaterThan(0)
20
22
  expect(new Set(list).size).toBe(list.length)
21
23
  }
@@ -24,6 +26,32 @@ describe("organizations wire contract — enums", () => {
24
26
  it("error codes are snake_case identifiers", () => {
25
27
  for (const code of ORG_ERROR_CODES) expect(code).toMatch(/^[a-z][a-z_]*[a-z]$/)
26
28
  })
29
+
30
+ it("USAGE_GROUP_BYS is exactly the report groupings plus none", () => {
31
+ expect([...USAGE_GROUP_BYS]).toEqual(["workspace", "member", "model", "day", "none"])
32
+ })
33
+
34
+ it("ORG_ERROR_CODES carries the P15 CSV write-ahead-audit code", () => {
35
+ expect(ORG_ERROR_CODES).toContain("audit_unavailable")
36
+ })
37
+ })
38
+
39
+ describe("usage wire types carry no cost/economics field (pricing-leak class)", () => {
40
+ it("no UsageReport* / UsageLogEntry field name is cost-shaped", () => {
41
+ const src = readFileSync(new URL("../organizations/views.ts", import.meta.url), "utf8")
42
+ .replace(/\/\*[\s\S]*?\*\//g, "")
43
+ .replace(/\/\/[^\n]*/g, "")
44
+ const start = src.indexOf("export interface UsageReportRow")
45
+ const end = src.indexOf("export interface UsageQuery")
46
+ expect(start).toBeGreaterThan(-1)
47
+ expect(end).toBeGreaterThan(start)
48
+ const usageTypes = src.slice(start, end)
49
+ // "markup" is deliberately NOT in this set: the creator-set app markup is a
50
+ // product term (the `app_markup` variance kind, `appMarkupAbsorbedCredits`),
51
+ // not platform economics — same carve-out as check-pricing-leaks'
52
+ // BARE_MARKUP_IS_PRODUCT_TERM. What must never appear is a $ / rate field.
53
+ expect(usageTypes).not.toMatch(/(cost|usd|margin|price|dollar)/i)
54
+ })
27
55
  })
28
56
 
29
57
  describe("organizations wire contract — settings request schemas", () => {
@@ -6,6 +6,9 @@ import {
6
6
  NEGATIVE_PROMPT_MAX,
7
7
  TTS_TEXT_MAX,
8
8
  SUNO_TITLE_MAX,
9
+ SUNO_HARD_CEILING,
10
+ SUNO_TEXT_MAX,
11
+ SUNO_MODELS,
9
12
  MAX_IMAGE_PROMPT_CHARS_BY_PROVIDER,
10
13
  MAX_VIDEO_PROMPT_CHARS_BY_PROVIDER,
11
14
  MAX_NEGATIVE_PROMPT_CHARS_BY_PROVIDER,
@@ -123,6 +126,39 @@ describe("per-model prompt length limits", () => {
123
126
  expect(getMaxSunoStyleChars("V5")).toBe(1000)
124
127
  expect(SUNO_TITLE_MAX).toBe(80)
125
128
  })
129
+ it("SUNO_HARD_CEILING is a generous route ceiling that can never reject a valid prompt", () => {
130
+ // The routes accept up to the ceiling and CLAMP to the per-version cap;
131
+ // a ceiling below any real cap would hard-reject before the clamp ran.
132
+ // The list is SUNO_MODELS plus `undefined` (schemas without a `model`
133
+ // field, e.g. replace-section). `V3_5` is NOT a route-accepted model —
134
+ // getMaxSunoPromptChars still names it, but do not add it here.
135
+ const versions = [...SUNO_MODELS, undefined]
136
+ for (const v of versions) {
137
+ for (const custom of [true, false]) {
138
+ expect(getMaxSunoPromptChars(v, custom)).toBeLessThanOrEqual(SUNO_HARD_CEILING)
139
+ }
140
+ expect(getMaxSunoStyleChars(v)).toBeLessThanOrEqual(SUNO_HARD_CEILING)
141
+ }
142
+ expect(SUNO_TEXT_MAX).toBeLessThanOrEqual(SUNO_HARD_CEILING)
143
+ expect(SUNO_HARD_CEILING).toBe(30000)
144
+ })
145
+ it("SUNO_TEXT_MAX pins the max prompt cap across every version × custom-mode combo and is >= every style cap", () => {
146
+ // SUNO_TEXT_MAX is the shared editor-side ceiling (maxLength / warn
147
+ // counter) for prompt-shaped Suno fields. Derive the expected value
148
+ // from the real version list so a future Suno version raising its
149
+ // custom-mode cap above today's 5000 fails this test instead of
150
+ // silently under-capping the editor.
151
+ let maxPromptCap = 0
152
+ for (const v of SUNO_MODELS) {
153
+ for (const custom of [true, false]) {
154
+ maxPromptCap = Math.max(maxPromptCap, getMaxSunoPromptChars(v, custom))
155
+ }
156
+ }
157
+ expect(SUNO_TEXT_MAX).toBe(maxPromptCap)
158
+ for (const v of SUNO_MODELS) {
159
+ expect(SUNO_TEXT_MAX).toBeGreaterThanOrEqual(getMaxSunoStyleChars(v))
160
+ }
161
+ })
126
162
  })
127
163
 
128
164
  describe("drift guards (registry hygiene)", () => {
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ sunoCreditType,
4
+ SUNO_VERSION_PRICED_OPERATIONS,
5
+ SUNO_SELECT_OPERATIONS,
6
+ SUNO_MODELS,
7
+ } from "../index.js"
8
+
9
+ describe("sunoCreditType — the ONE implementation of the Suno route pricing contract", () => {
10
+ it("prices generate/cover/extend by model version", () => {
11
+ expect(sunoCreditType("V5_5", "suno-generate")).toBe("suno-v5_5")
12
+ expect(sunoCreditType("V5", "suno-cover")).toBe("suno-v5")
13
+ expect(sunoCreditType("V5_5", "suno-extend")).toBe("suno-v5_5")
14
+ })
15
+
16
+ it("falls back to the operation key for versions with no dedicated price", () => {
17
+ expect(sunoCreditType("V4", "suno-generate")).toBe("suno-generate")
18
+ expect(sunoCreditType("V4_5ALL", "suno-cover")).toBe("suno-cover")
19
+ expect(sunoCreditType(undefined, "suno-extend")).toBe("suno-extend")
20
+ })
21
+
22
+ // The reason this function is gated rather than a bare version map: these four
23
+ // routes charge a FLAT per-operation key no matter which version the node
24
+ // carries (routes/suno.ts:648, :852, :907, :1016). A quote of "suno-v5_5" for
25
+ // one of them is a key the route never charges.
26
+ it.each([
27
+ "suno-mashup",
28
+ "suno-add-instrumental",
29
+ "suno-add-vocals",
30
+ "suno-upload-extend",
31
+ ])("keeps the flat operation key for %s regardless of version", (operation) => {
32
+ for (const model of SUNO_MODELS) {
33
+ expect(sunoCreditType(model, operation)).toBe(operation)
34
+ }
35
+ expect(sunoCreditType(undefined, operation)).toBe(operation)
36
+ })
37
+
38
+ it("returns an unknown operation unchanged (never invents a key)", () => {
39
+ expect(sunoCreditType("V5", "suno-voice")).toBe("suno-voice")
40
+ expect(sunoCreditType("V5_5", "not-a-suno-op")).toBe("not-a-suno-op")
41
+ })
42
+
43
+ it("exports exactly the three version-priced operations", () => {
44
+ expect([...SUNO_VERSION_PRICED_OPERATIONS]).toEqual([
45
+ "suno-generate",
46
+ "suno-cover",
47
+ "suno-extend",
48
+ ])
49
+ })
50
+
51
+ it("exports exactly the seven select operations", () => {
52
+ expect(SUNO_SELECT_OPERATIONS.length).toBe(7)
53
+ })
54
+ })
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { stripDerivedAnalysisFields, videoAnalysisResultSchema } from "../video-analysis.js"
3
+
4
+ /** A real result shape (validated below) so the strip list is tested against
5
+ * the canonical schema, not an invented fixture. */
6
+ const analysis = {
7
+ meta: { durationSec: 12, width: 1920, height: 1080, aspectRatio: "16:9", title: "Clip" },
8
+ slots: [
9
+ { slotId: "hero", label: "Hero", source: "wired-character", role: "person", description: "tan man, mustache, black tee", refImageUrl: "https://r2.example/hero.png" },
10
+ ],
11
+ scenes: [
12
+ { sceneNumber: 1, startSec: 0, endSec: 4, label: "Hook", shotType: "Medium Close-Up", camera: "slow push-in", visual: "{slot:hero} waves", visualResolved: "Hero waves", slotRefs: ["hero"], oversized: false, audio: [] },
13
+ { sceneNumber: 2, startSec: 4, endSec: 12, label: "Turn", shotType: "Medium Close-Up", camera: "static", visual: "an empty street", visualResolved: "an empty street", slotRefs: [], oversized: true, audio: [] },
14
+ ],
15
+ variationFolds: [{ slotId: "hero", variationId: "v2", label: "coat" }],
16
+ warnings: ["auto-cast dropped one duplicate line"],
17
+ }
18
+
19
+ describe("stripDerivedAnalysisFields", () => {
20
+ it("the fixture is a real result (the strip list is tested against the canonical shape)", () => {
21
+ expect(videoAnalysisResultSchema.safeParse(analysis).success).toBe(true)
22
+ })
23
+
24
+ it("drops the derived top-level and per-scene keys and keeps everything else, refImageUrl included", () => {
25
+ const out = stripDerivedAnalysisFields(analysis) as Record<string, unknown>
26
+ expect(out).not.toHaveProperty("warnings")
27
+ expect(out).not.toHaveProperty("variationFolds")
28
+ expect(out.meta).toEqual(analysis.meta)
29
+ expect((out.slots as Array<Record<string, unknown>>)[0].refImageUrl).toBe("https://r2.example/hero.png")
30
+ const scenes = out.scenes as Array<Record<string, unknown>>
31
+ expect(scenes).toHaveLength(2)
32
+ for (const scene of scenes) {
33
+ expect(scene).not.toHaveProperty("visualResolved")
34
+ expect(scene).not.toHaveProperty("slotRefs")
35
+ expect(scene).not.toHaveProperty("oversized")
36
+ expect(scene).toHaveProperty("visual")
37
+ expect(scene).toHaveProperty("audio")
38
+ expect(scene).toHaveProperty("sceneNumber")
39
+ }
40
+ })
41
+
42
+ it("never mutates its input", () => {
43
+ const frozen = JSON.parse(JSON.stringify(analysis))
44
+ stripDerivedAnalysisFields(analysis)
45
+ expect(analysis).toEqual(frozen)
46
+ })
47
+
48
+ it("returns a non-object input untouched and tolerates a non-object scene entry", () => {
49
+ expect(stripDerivedAnalysisFields("nope")).toBe("nope")
50
+ expect(stripDerivedAnalysisFields(null)).toBe(null)
51
+ expect(stripDerivedAnalysisFields([1])).toEqual([1])
52
+ const out = stripDerivedAnalysisFields({ scenes: [1, "x", null] }) as { scenes: unknown[] }
53
+ expect(out.scenes).toEqual([1, "x", null])
54
+ })
55
+
56
+ it("is roughly half the bytes of the full result on a real-shaped scene", () => {
57
+ const full = JSON.stringify(analysis).length
58
+ const stripped = JSON.stringify(stripDerivedAnalysisFields(analysis)).length
59
+ expect(stripped).toBeLessThan(full)
60
+ })
61
+ })