Skip to content

Commit 4fabd83

Browse files
test(write): pin the artifact-boundary invariants end to end
Every one of these reproduced as a defect before the fix: the `--out` file carrying a title/style prompt/tag list into `--lyrics-file`, the emitted command naming `song.txt` regardless of the real path, an unescaped `She Said "Go"`, `--mood` losing to the genre's `[Mood:]` tag, instrumental mode shipping lyric placeholders and omitting `--instrumental`, priming succeeding with no target/objective/domain, and `generate` accepting a raw scaffold. The suite fails if any of them returns. Claude-Session: https://claude.ai/code/session_01JpQKiFkvQHL5W1v4chubdR
1 parent 389758f commit 4fabd83

1 file changed

Lines changed: 236 additions & 0 deletions

File tree

tests/write_contract.rs

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
//! `suno write` end-to-end invariants. The central one: the file named by the
2+
//! emitted generate command exists, is directly consumable by
3+
//! `generate --lyrics-file`, carries nothing that would be sung as lyrics, and
4+
//! reflects every selected control.
5+
//!
6+
//! `write` reads neither config nor auth nor network, so the scrubbed `suno()`
7+
//! helper is enough; only the placeholder preflight needs an isolated home.
8+
9+
mod common;
10+
use common::{suno, suno_in};
11+
use tempfile::TempDir;
12+
13+
fn write_json(args: &[&str]) -> serde_json::Value {
14+
let out = suno()
15+
.arg("write")
16+
.args(args)
17+
.arg("--json")
18+
.output()
19+
.unwrap();
20+
assert!(out.status.success(), "write failed: {args:?}");
21+
serde_json::from_slice(&out.stdout).expect("valid JSON envelope")
22+
}
23+
24+
#[test]
25+
fn out_file_is_lyrics_only_and_matches_the_envelope() {
26+
let tmp = TempDir::new().unwrap();
27+
let song = tmp.path().join("song.txt");
28+
let path = song.to_str().unwrap();
29+
let v = write_json(&["--genre", "pop", "--title", "Golden Hour", "--out", path]);
30+
31+
let contents = std::fs::read_to_string(&song).unwrap();
32+
assert!(contents.contains("[Chorus]"));
33+
// Nothing that would be handed to Suno as lyrics and sung.
34+
for banned in ["Style Prompt", "Suno Tags", "Golden Hour", "---"] {
35+
assert!(
36+
!contents.contains(banned),
37+
"--out must be lyrics-only, found '{banned}'"
38+
);
39+
}
40+
// The envelope's `structure` is byte-identical to what landed on disk.
41+
assert_eq!(v["data"]["structure"].as_str().unwrap(), contents);
42+
assert_eq!(v["data"]["written"].as_str().unwrap(), path);
43+
// The metadata lives in the envelope instead.
44+
assert_eq!(v["data"]["title"], "Golden Hour");
45+
assert!(!v["data"]["style_prompt"].as_str().unwrap().is_empty());
46+
}
47+
48+
#[test]
49+
fn project_out_carries_the_composite_document() {
50+
let tmp = TempDir::new().unwrap();
51+
let lyrics = tmp.path().join("song.txt");
52+
let project = tmp.path().join("project.md");
53+
write_json(&[
54+
"--genre",
55+
"pop",
56+
"--title",
57+
"Golden Hour",
58+
"--out",
59+
lyrics.to_str().unwrap(),
60+
"--project-out",
61+
project.to_str().unwrap(),
62+
]);
63+
let doc = std::fs::read_to_string(&project).unwrap();
64+
for anchor in ["Golden Hour", "Style Prompt", "Suno Tags", "[Chorus]"] {
65+
assert!(doc.contains(anchor), "project doc missing {anchor}");
66+
}
67+
}
68+
69+
#[test]
70+
fn next_action_points_at_the_real_file_and_is_absent_without_one() {
71+
let tmp = TempDir::new().unwrap();
72+
let song = tmp.path().join("out-song.txt");
73+
let path = song.to_str().unwrap();
74+
let v = write_json(&[
75+
"--genre",
76+
"pop",
77+
"--title",
78+
r#"She Said "Go""#,
79+
"--out",
80+
path,
81+
]);
82+
let argv: Vec<String> = serde_json::from_value(v["data"]["next_action"]["argv"].clone())
83+
.expect("next_action.argv must be a string array");
84+
let i = argv.iter().position(|a| a == "--lyrics-file").unwrap();
85+
assert_eq!(argv[i + 1], path);
86+
assert!(std::path::Path::new(&argv[i + 1]).exists());
87+
let cmd = v["data"]["next_action"]["command"].as_str().unwrap();
88+
assert!(cmd.contains(r#"'She Said "Go"'"#), "unescaped title: {cmd}");
89+
assert!(!cmd.contains("song.txt --model"));
90+
91+
// Without --out there is no file, so no runnable command may be emitted.
92+
let v = write_json(&["--genre", "pop"]);
93+
assert!(v["data"]["next_action"].is_null());
94+
assert_eq!(v["data"]["ready_to_generate"], false);
95+
assert!(
96+
v["data"]["missing_requirements"][0]
97+
.as_str()
98+
.unwrap()
99+
.contains("--out")
100+
);
101+
}
102+
103+
#[test]
104+
fn overrides_reach_every_field() {
105+
let v = write_json(&[
106+
"--genre",
107+
"modern-pop", // Uplifting, 120 BPM, female by default
108+
"--mood",
109+
"dark and brooding",
110+
"--vocal",
111+
"male",
112+
"--bpm",
113+
"88",
114+
]);
115+
let style = v["data"]["style_prompt"].as_str().unwrap();
116+
let structure = v["data"]["structure"].as_str().unwrap();
117+
let tags = v["data"]["suno_tags"].as_str().unwrap();
118+
assert!(style.contains("dark and brooding") && style.contains("88 BPM"));
119+
assert!(structure.contains("[Mood: Dark]") && structure.contains("[Male Vocal]"));
120+
assert!(!structure.to_lowercase().contains("uplifting"));
121+
assert!(tags.contains("dark") && !tags.contains("uplifting"));
122+
assert!(tags.contains("male vocals") && !tags.contains("female vocals"));
123+
}
124+
125+
#[test]
126+
fn instrumental_is_coherent() {
127+
let tmp = TempDir::new().unwrap();
128+
let beat = tmp.path().join("beat.txt");
129+
let v = write_json(&[
130+
"--genre",
131+
"lo-fi",
132+
"--instrumental",
133+
"--out",
134+
beat.to_str().unwrap(),
135+
]);
136+
let contents = std::fs::read_to_string(&beat).unwrap();
137+
assert!(!contents.contains('<'), "no fill-me slots: {contents}");
138+
assert_eq!(v["data"]["placeholders_remaining"], 0);
139+
assert_eq!(v["data"]["ready_to_generate"], true);
140+
let argv: Vec<String> =
141+
serde_json::from_value(v["data"]["next_action"]["argv"].clone()).unwrap();
142+
assert!(argv.iter().any(|a| a == "--instrumental"));
143+
}
144+
145+
#[test]
146+
fn priming_without_consent_fields_exits_3() {
147+
let out = suno()
148+
.args(["write", "--mode", "priming", "--json"])
149+
.output()
150+
.unwrap();
151+
assert_eq!(out.status.code(), Some(3));
152+
let v: serde_json::Value = serde_json::from_slice(&out.stderr).unwrap();
153+
assert_eq!(v["status"], "error");
154+
let msg = v["error"]["message"].as_str().unwrap();
155+
for flag in ["--target", "--objective", "--domain"] {
156+
assert!(msg.contains(flag), "{msg}");
157+
}
158+
// No scaffold and no handoff for an unusable request.
159+
assert!(out.stdout.is_empty());
160+
}
161+
162+
#[test]
163+
fn priming_with_consent_fields_shapes_the_scaffold() {
164+
let v = write_json(&[
165+
"--mode",
166+
"priming",
167+
"--target",
168+
"anonymised batch (n=40)",
169+
"--objective",
170+
"increase recall of brand X",
171+
"--domain",
172+
"marketing",
173+
]);
174+
assert_eq!(v["data"]["mode"], "priming");
175+
assert_eq!(v["data"]["theme"], "increase recall of brand X");
176+
let structure = v["data"]["structure"].as_str().unwrap();
177+
assert!(structure.contains("increase recall of brand X"));
178+
// The research artefact belongs in the envelope, never in the lyrics.
179+
assert!(!structure.contains("Prime-Stack"));
180+
assert!(
181+
v["data"]["priming"]["prime_stack_map"]
182+
.as_str()
183+
.unwrap()
184+
.contains("Prime-Stack Map")
185+
);
186+
}
187+
188+
#[test]
189+
fn generate_refuses_unresolved_scaffold_placeholders() {
190+
// Credit protection: an unfilled scaffold must never reach the API. The
191+
// preflight runs before auth, so this needs no credentials.
192+
let tmp = TempDir::new().unwrap();
193+
let song = tmp.path().join("song.txt");
194+
suno_in(tmp.path())
195+
.args([
196+
"write",
197+
"--genre",
198+
"pop",
199+
"--out",
200+
song.to_str().unwrap(),
201+
"--json",
202+
])
203+
.assert()
204+
.success();
205+
206+
let out = suno_in(tmp.path())
207+
.args([
208+
"generate",
209+
"--title",
210+
"Draft",
211+
"--tags",
212+
"pop",
213+
"--lyrics-file",
214+
song.to_str().unwrap(),
215+
"--json",
216+
])
217+
.output()
218+
.unwrap();
219+
assert_eq!(out.status.code(), Some(3));
220+
let v: serde_json::Value = serde_json::from_slice(&out.stderr).unwrap();
221+
let msg = v["error"]["message"].as_str().unwrap();
222+
assert!(msg.contains("placeholder"), "{msg}");
223+
// Name the offending lines so the caller can fix them without guessing.
224+
assert!(msg.contains("line(s)") && msg.chars().any(|c| c.is_ascii_digit()));
225+
}
226+
227+
#[test]
228+
fn write_help_states_the_out_and_redirection_contract() {
229+
let out = suno().args(["write", "--help"]).output().unwrap();
230+
assert!(out.status.success());
231+
let help = String::from_utf8_lossy(&out.stdout);
232+
assert!(help.contains("--out"));
233+
// The piped-vs-TTY contradiction the review flagged must stay fixed.
234+
assert!(!help.contains("Plain text to stdout"));
235+
assert!(help.to_lowercase().contains("redirection"));
236+
}

0 commit comments

Comments
 (0)