flux3_ai_prompt_tools 0.1.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.
data/test/cli_test.rb ADDED
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helper"
4
+
5
+ class CliTest < Flux3Test
6
+ def test_help_and_version
7
+ stdout, stderr, status = run_cli("help")
8
+ assert_equal 0, status.exitstatus
9
+ assert_includes stdout, "flux3-prompt"
10
+ assert_includes stdout, "https://flux3-ai.com/"
11
+ assert_equal "", stderr
12
+
13
+ stdout, _stderr, status = run_cli("version")
14
+ assert_equal 0, status.exitstatus
15
+ assert_equal "#{Flux3AiPromptTools::VERSION}\n", stdout
16
+
17
+ _stdout, stderr, status = run_cli
18
+ assert_equal 2, status.exitstatus
19
+ assert_includes stderr, ""
20
+ assert_includes stdout_of_no_args, "Usage:"
21
+ end
22
+
23
+ def test_assemble_prints_prompt_and_manifest
24
+ stdout, stderr, status = run_cli(
25
+ "assemble",
26
+ "--preset", "product_still",
27
+ "--subject", "a matte cream kettle",
28
+ "--negative", "text",
29
+ "--negative", "watermark",
30
+ "--aspect-ratio", "4:5",
31
+ "--seed", "1204"
32
+ )
33
+ assert_equal 0, status.exitstatus, stderr
34
+ assert_equal "", stderr
35
+ assert_includes stdout, "a matte cream kettle."
36
+ assert_includes stdout, "photograph."
37
+ refute_includes stdout, "watermark"
38
+
39
+ stdout, stderr, status = run_cli(
40
+ "assemble",
41
+ "--subject", "a matte cream kettle",
42
+ "--negative", "watermark",
43
+ "--manifest"
44
+ )
45
+ assert_equal 0, status.exitstatus, stderr
46
+ payload = JSON.parse(stdout)
47
+ assert_equal "watermark", payload["negative_prompt"]
48
+ assert_equal "a matte cream kettle.", payload["prompt"]
49
+ assert_equal true, payload["ok"]
50
+ end
51
+
52
+ def test_assemble_exit_statuses
53
+ _stdout, stderr, status = run_cli("assemble")
54
+ assert_equal 1, status.exitstatus
55
+ assert_includes stderr, "/subject required"
56
+
57
+ _stdout, stderr, status = run_cli("assemble", "--subject", "a bowl", "--seed", "nope")
58
+ assert_equal 2, status.exitstatus
59
+ assert_match(/seed/, stderr)
60
+
61
+ _stdout, stderr, status = run_cli("assemble", "--subject", "a bowl", "--layout", "poem")
62
+ assert_equal 2, status.exitstatus
63
+ assert_match(/layout/, stderr)
64
+
65
+ _stdout, stderr, status = run_cli("nope")
66
+ assert_equal 2, status.exitstatus
67
+ assert_match(/unknown command/, stderr)
68
+ end
69
+
70
+ def test_validate_and_batch_examples
71
+ stdout, stderr, status = run_cli("validate", File.join(ROOT, "examples", "kettle.json"))
72
+ assert_equal 0, status.exitstatus, stderr
73
+ assert_equal "valid: 1 record(s)\n", stdout
74
+
75
+ stdout, stderr, status = run_cli("validate", File.join(ROOT, "examples", "batch.jsonl"))
76
+ assert_equal 0, status.exitstatus, stderr
77
+ assert_equal "valid: 2 record(s)\n", stdout
78
+
79
+ with_tmpdir do |dir|
80
+ jsonl_path = File.join(dir, "out.jsonl")
81
+ _stdout, stderr, status = run_cli(
82
+ "batch", File.join(ROOT, "examples", "batch.jsonl"),
83
+ "--format", "jsonl",
84
+ "-o", jsonl_path
85
+ )
86
+ assert_equal 0, status.exitstatus, stderr
87
+ assert_match(/wrote 2 record/, stderr)
88
+ lines = File.readlines(jsonl_path, chomp: true)
89
+ assert_equal 2, lines.length
90
+ first = JSON.parse(lines[0])
91
+ second = JSON.parse(lines[1])
92
+ assert_equal "kettle-01", first["id"]
93
+ assert_equal "natural", first["layout"]
94
+ assert_includes first["prompt"], "a matte cream ceramic pour-over kettle"
95
+ refute_includes first["prompt"], "Local reminder"
96
+ assert_equal "cup-02", second["id"]
97
+ assert_equal "tagged", second["layout"]
98
+ assert_equal "3:4", second["parameters"]["aspect_ratio"]
99
+ refute_includes second["prompt"], "\n"
100
+
101
+ json_path = File.join(dir, "out.json")
102
+ _stdout, stderr, status = run_cli(
103
+ "batch", File.join(ROOT, "examples", "kettle.json"),
104
+ "-o", json_path
105
+ )
106
+ assert_equal 0, status.exitstatus, stderr
107
+ document = JSON.parse(File.read(json_path))
108
+ assert_equal true, document["ok"]
109
+ assert_equal "kettle-01", document["records"][0]["id"]
110
+ assert_equal "Local reminder only. This file is not sent anywhere.", document["records"][0]["note"]
111
+ refute_includes document["records"][0]["prompt"], "Local reminder"
112
+ end
113
+ end
114
+
115
+ def test_batch_stdin_keep_going_and_strict_writes_nothing
116
+ stdout, stderr, status = run_cli(
117
+ "batch", "-",
118
+ "--input-format", "jsonl",
119
+ stdin: "{\"subject\":\"a bowl\"}\n{\"subject\":\"a cup\",\"layout\":\"labeled\"}\n"
120
+ )
121
+ assert_equal 0, status.exitstatus, stderr
122
+ rows = stdout.lines.map { |line| JSON.parse(line) }
123
+ assert_equal %w[item-0001 item-0002], rows.map { |row| row["id"] }
124
+ assert_includes rows[1]["prompt"], "Subject: a cup"
125
+
126
+ with_tmpdir do |dir|
127
+ source = File.join(dir, "mixed.jsonl")
128
+ File.write(source, "{\"subject\":\"a bowl\"}\n{\"mood\":\"quiet\"}\n")
129
+ output = File.join(dir, "mixed-out.jsonl")
130
+ _stdout, stderr, status = run_cli("batch", source, "--keep-going", "-o", output)
131
+ assert_equal 1, status.exitstatus
132
+ assert_match(/1 failed/, stderr)
133
+ rows = File.readlines(output, chomp: true).map { |line| JSON.parse(line) }
134
+ assert_equal true, rows[0]["ok"]
135
+ assert_equal false, rows[1]["ok"]
136
+ assert_nil rows[1]["prompt"]
137
+
138
+ blocked = File.join(dir, "blocked.jsonl")
139
+ _stdout, stderr, status = run_cli("batch", source, "-o", blocked)
140
+ assert_equal 1, status.exitstatus
141
+ assert_includes stderr, "/records/1/subject required"
142
+ refute File.exist?(blocked)
143
+ end
144
+ end
145
+
146
+ def test_missing_file_and_schema_presets
147
+ _stdout, stderr, status = run_cli("validate", "missing-file.json")
148
+ assert_equal 1, status.exitstatus
149
+ assert_match(/does not exist/, stderr)
150
+
151
+ stdout, stderr, status = run_cli("schema")
152
+ assert_equal 0, status.exitstatus, stderr
153
+ schema = JSON.parse(stdout)
154
+ assert_equal ["subject"], schema["required"]
155
+ assert_equal false, schema["additionalProperties"]
156
+ assert_equal Flux3AiPromptTools::Presets::NAMES, schema["properties"]["preset"]["enum"]
157
+
158
+ stdout, _stderr, status = run_cli("presets")
159
+ assert_equal 0, status.exitstatus
160
+ names = JSON.parse(stdout).map { |preset| preset["name"] }
161
+ assert_equal Flux3AiPromptTools::Presets::NAMES, names
162
+ end
163
+
164
+ def stdout_of_no_args
165
+ stdout, _stderr, _status = run_cli
166
+ stdout
167
+ end
168
+ end
data/test/helper.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+
5
+ require "minitest/autorun"
6
+ require "flux3_ai_prompt_tools"
7
+ require "json"
8
+ require "open3"
9
+ require "rbconfig"
10
+ require "tmpdir"
11
+
12
+ class Flux3Test < Minitest::Test
13
+ ROOT = File.expand_path("..", __dir__)
14
+
15
+ def run_cli(*args, stdin: nil)
16
+ env = { "RUBYLIB" => File.join(ROOT, "lib") }
17
+ command = [RbConfig.ruby, File.join(ROOT, "bin", "flux3-prompt"), *args.map(&:to_s)]
18
+ Open3.capture3(env, *command, stdin_data: stdin)
19
+ end
20
+
21
+ def with_tmpdir
22
+ Dir.mktmpdir("flux3-prompt") do |dir|
23
+ yield dir
24
+ end
25
+ end
26
+
27
+ def codes(issues)
28
+ issues.map(&:code)
29
+ end
30
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helper"
4
+
5
+ class ManifestTest < Flux3Test
6
+ def test_auto_ids_skip_explicit_ids_and_preserve_order
7
+ manifest = Flux3AiPromptTools.build_manifest(
8
+ [
9
+ { "id" => "item-0001", "subject" => "a bowl" },
10
+ { "subject" => "a cup" },
11
+ { "subject" => "a plate", "layout" => "tagged" }
12
+ ]
13
+ )
14
+ assert_equal %w[item-0001 item-0002 item-0003], manifest.records.map(&:id)
15
+ assert_equal "natural", manifest.records[1].layout
16
+ assert_equal "tagged", manifest.records[2].layout
17
+ assert_equal "a plate", manifest.records[2].prompt
18
+ assert manifest.ok?
19
+ document = JSON.parse(manifest.to_json_document)
20
+ assert_equal true, document["ok"]
21
+ assert_equal 3, document["records"].length
22
+ lines = manifest.to_jsonl.lines
23
+ assert_equal 3, lines.length
24
+ assert_equal "a cup.", JSON.parse(lines[1])["prompt"]
25
+ end
26
+
27
+ def test_duplicate_id_and_duplicate_job
28
+ error = assert_raises(Flux3AiPromptTools::ValidationError) do
29
+ Flux3AiPromptTools.build_manifest(
30
+ [
31
+ { "id" => "bowl", "subject" => "a bowl", "seed" => 1 },
32
+ { "id" => "bowl", "subject" => "a cup" },
33
+ { "subject" => "a bowl", "seed" => 1 },
34
+ { "subject" => "a bowl", "seed" => 2 }
35
+ ]
36
+ )
37
+ end
38
+ assert_includes error.issues.map(&:code), "duplicate_id"
39
+ assert_includes error.issues.map(&:code), "duplicate_job"
40
+
41
+ manifest = Flux3AiPromptTools.build_manifest(
42
+ [
43
+ { "subject" => "a bowl", "seed" => 1 },
44
+ { "subject" => "a bowl", "seed" => 1 },
45
+ { "subject" => "a bowl", "seed" => 2 }
46
+ ],
47
+ keep_going: true
48
+ )
49
+ assert_equal false, manifest.ok?
50
+ assert_equal true, manifest.records[0].ok
51
+ assert_equal false, manifest.records[1].ok
52
+ assert_nil manifest.records[1].prompt
53
+ assert_equal "duplicate_job", manifest.records[1].errors.first.code
54
+ assert_equal true, manifest.records[2].ok
55
+ assert_equal 2, manifest.records[2].parameters["seed"]
56
+ end
57
+
58
+ def test_keep_going_reports_field_errors_without_dropping_later_records
59
+ manifest = Flux3AiPromptTools.build_manifest(
60
+ [
61
+ { "mood" => "quiet" },
62
+ { "subject" => "a bowl" }
63
+ ],
64
+ keep_going: true
65
+ )
66
+ assert_equal false, manifest.records[0].ok
67
+ assert_equal "required", manifest.records[0].errors.first.code
68
+ assert_equal "/records/0/subject", manifest.records[0].errors.first.path
69
+ assert_equal "a bowl.", manifest.records[1].prompt
70
+ end
71
+
72
+ def test_strict_batch_collects_every_record_error
73
+ error = assert_raises(Flux3AiPromptTools::ValidationError) do
74
+ Flux3AiPromptTools.build_manifest(
75
+ [
76
+ { "subject" => "" },
77
+ { "subject" => "a bowl", "aspect_ratio" => "wide" }
78
+ ]
79
+ )
80
+ end
81
+ assert_equal ["/records/0/subject", "/records/1/aspect_ratio"], error.issues.map(&:path)
82
+ end
83
+
84
+ def test_length_errors_are_prefixed_in_a_batch
85
+ error = assert_raises(Flux3AiPromptTools::ValidationError) do
86
+ Flux3AiPromptTools.build_manifest(
87
+ [{ "subject" => "a bowl" }],
88
+ limits: Flux3AiPromptTools::Limits.new(max_prompt_chars: 3)
89
+ )
90
+ end
91
+ assert_equal "/records/0/prompt", error.issues.first.path
92
+ end
93
+
94
+ def test_empty_and_oversized_batches
95
+ assert_raises(Flux3AiPromptTools::InputError) do
96
+ Flux3AiPromptTools.build_manifest([])
97
+ end
98
+ assert_raises(Flux3AiPromptTools::InputError) do
99
+ Flux3AiPromptTools.build_manifest(
100
+ [{ "subject" => "a bowl" }, { "subject" => "a cup" }],
101
+ limits: Flux3AiPromptTools::Limits.new(max_records: 1)
102
+ )
103
+ end
104
+ end
105
+
106
+ def test_bad_layout_argument
107
+ error = assert_raises(ArgumentError) do
108
+ Flux3AiPromptTools.build_manifest([{ "subject" => "a bowl" }], layout: "poem")
109
+ end
110
+ assert_match(/layout/, error.message)
111
+ end
112
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require_relative "helper"
5
+
6
+ class PackagingTest < Flux3Test
7
+ def test_gemspec_metadata_and_packaged_source
8
+ spec = Gem::Specification.load(File.join(ROOT, "flux3_ai_prompt_tools.gemspec"))
9
+ assert_equal "flux3_ai_prompt_tools", spec.name
10
+ assert_equal Flux3AiPromptTools::VERSION, spec.version.to_s
11
+ assert_equal ["Andy Wang"], spec.authors
12
+ assert_nil spec.email
13
+ assert_equal "https://flux3-ai.com/", spec.homepage
14
+ assert_equal "https://flux3-ai.com/", spec.metadata["homepage_uri"]
15
+ assert_equal "MIT", spec.license
16
+ assert_equal "https://rubygems.org", spec.metadata["allowed_push_host"]
17
+ refute spec.metadata.key?("rubygems_mfa_required")
18
+ assert_nil spec.metadata["rubygems_mfa_required"]
19
+ assert_nil spec.metadata["source_code_uri"]
20
+ assert_empty spec.runtime_dependencies
21
+ assert_equal Gem::Requirement.new(">= 3.1.0"), spec.required_ruby_version
22
+ %w[
23
+ lib/flux3_ai_prompt_tools.rb
24
+ bin/flux3-prompt
25
+ README.md
26
+ LICENSE
27
+ CHANGELOG.md
28
+ examples/kettle.json
29
+ examples/batch.jsonl
30
+ test/validator_test.rb
31
+ test/cli_test.rb
32
+ flux3_ai_prompt_tools.gemspec
33
+ ].each do |path|
34
+ assert_includes spec.files, path
35
+ end
36
+ assert_operator spec.summary.length, :<=, 144
37
+ refute_match(/@/, spec.summary)
38
+ refute_match(/@/, spec.description)
39
+ end
40
+
41
+ def test_readme_describes_real_behavior_and_limits
42
+ readme = File.read(File.join(ROOT, "README.md"))
43
+ words = readme.scan(/[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*/)
44
+ assert_operator words.length, :>=, 200
45
+ assert_includes readme, "https://flux3-ai.com/"
46
+ assert_includes readme, "does not generate images"
47
+ assert_includes readme, "not a Black Forest Labs product"
48
+ assert_includes readme, "flux_3_org"
49
+ assert_includes readme, "## Limitations"
50
+ assert_includes readme, "rake test"
51
+ assert_includes readme, "gem install --user-install flux3_ai_prompt_tools-0.1.0.gem"
52
+ end
53
+
54
+ def test_library_and_executable_do_not_reference_network_clients
55
+ roots = [File.join(ROOT, "lib"), File.join(ROOT, "bin")]
56
+ forbidden = %w[Net::HTTP OpenURI TCPSocket UDPSocket Resolv net/http open-uri socket telemetry api_key]
57
+ offenders = []
58
+ roots.each do |root|
59
+ Dir.glob(File.join(root, "**/*")).each do |path|
60
+ next unless File.file?(path)
61
+
62
+ text = File.read(path)
63
+ forbidden.each do |token|
64
+ offenders << "#{path} contains #{token}" if text.include?(token)
65
+ end
66
+ end
67
+ end
68
+ assert_empty offenders
69
+ end
70
+
71
+ def test_examples_round_trip_through_the_library
72
+ kettle = Flux3AiPromptTools.load_file(File.join(ROOT, "examples", "kettle.json"))
73
+ result = Flux3AiPromptTools.assemble(kettle.fetch(0))
74
+ assert_equal "kettle-01", result.id
75
+ assert_includes result.prompt, "editorial product photography style"
76
+ refute_includes result.prompt, "Local reminder"
77
+ assert_equal ["text", "watermark", "extra handle"], result.negative_prompt.split(", ")
78
+
79
+ batch = Flux3AiPromptTools.load_file(File.join(ROOT, "examples", "batch.jsonl"))
80
+ manifest = Flux3AiPromptTools.build_manifest(batch)
81
+ assert_equal 2, manifest.records.length
82
+ assert manifest.ok?
83
+ end
84
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helper"
4
+
5
+ class ReaderTest < Flux3Test
6
+ def test_json_object_array_jsonl_and_bom
7
+ object = Flux3AiPromptTools::Reader.load('{"subject":"a bowl"}', format: "json")
8
+ assert_equal [{ "subject" => "a bowl" }], object
9
+
10
+ array = Flux3AiPromptTools::Reader.load('[{"subject":"a bowl"},{"subject":"a cup"}]', format: "auto")
11
+ assert_equal 2, array.length
12
+
13
+ jsonl = Flux3AiPromptTools::Reader.load("\n{\"subject\":\"a bowl\"}\n\n{\"subject\":\"a cup\"}\n", format: "jsonl")
14
+ assert_equal %w[a\ bowl a\ cup], jsonl.map { |row| row["subject"] }
15
+
16
+ bom = Flux3AiPromptTools::Reader.load("\uFEFF{\"subject\":\"a bowl\"}", format: "auto")
17
+ assert_equal "a bowl", bom.first["subject"]
18
+ end
19
+
20
+ def test_jsonl_reports_the_line_number
21
+ error = assert_raises(Flux3AiPromptTools::InputError) do
22
+ Flux3AiPromptTools::Reader.load("{\"subject\":\"ok\"}\nnot-json\n", format: "jsonl", filename: "batch.jsonl")
23
+ end
24
+ assert_equal 2, error.line
25
+ assert_match(/batch.jsonl:2/, error.message)
26
+ end
27
+
28
+ def test_rejects_non_objects_empty_input_and_oversize_files
29
+ assert_raises(Flux3AiPromptTools::InputError) do
30
+ Flux3AiPromptTools::Reader.load("[]", format: "json")
31
+ end
32
+ assert_raises(Flux3AiPromptTools::InputError) do
33
+ Flux3AiPromptTools::Reader.load("[1]", format: "json")
34
+ end
35
+ assert_raises(Flux3AiPromptTools::InputError) do
36
+ Flux3AiPromptTools::Reader.load(" ", format: "auto")
37
+ end
38
+
39
+ with_tmpdir do |dir|
40
+ path = File.join(dir, "too-big.json")
41
+ File.binwrite(path, "{\"subject\":\"a bowl\"}")
42
+ error = assert_raises(Flux3AiPromptTools::InputError) do
43
+ Flux3AiPromptTools::Reader.load_file(
44
+ path,
45
+ limits: Flux3AiPromptTools::Limits.new(max_input_bytes: 4)
46
+ )
47
+ end
48
+ assert_match(/bytes/, error.message)
49
+
50
+ missing = assert_raises(Flux3AiPromptTools::InputError) do
51
+ Flux3AiPromptTools::Reader.load_file(File.join(dir, "missing.json"))
52
+ end
53
+ assert_match(/does not exist/, missing.message)
54
+
55
+ binary = File.join(dir, "bad.json")
56
+ File.binwrite(binary, "\xff\xfe")
57
+ assert_raises(Flux3AiPromptTools::InputError) do
58
+ Flux3AiPromptTools::Reader.load_file(binary)
59
+ end
60
+ end
61
+ end
62
+
63
+ def test_extension_selects_the_reader
64
+ with_tmpdir do |dir|
65
+ path = File.join(dir, "rows.jsonl")
66
+ File.write(path, "{\"subject\":\"a bowl\"}\n{\"subject\":\"a cup\"}\n")
67
+ rows = Flux3AiPromptTools.load_file(path)
68
+ assert_equal 2, rows.length
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helper"
4
+
5
+ class ValidatorTest < Flux3Test
6
+ def test_subject_is_required_and_symbol_keys_work
7
+ issues = Flux3AiPromptTools.validate({ style: "quiet" })
8
+ assert_equal ["required"], codes(issues)
9
+ assert_equal "/subject", issues.first.path
10
+
11
+ result = Flux3AiPromptTools.assemble({ subject: " a red bowl " })
12
+ assert_equal "a red bowl.", result.prompt
13
+ end
14
+
15
+ def test_blank_optional_string_is_an_error
16
+ issues = Flux3AiPromptTools.validate({ "subject" => "a bowl", "mood" => " " })
17
+ assert_equal ["blank"], codes(issues)
18
+ assert_equal "/mood", issues.first.path
19
+ end
20
+
21
+ def test_control_characters_and_line_separators_are_rejected
22
+ ["a\nbowl", "a\u0000bowl", "a\u007Fbowl", "a\u2028bowl", "a\u2029bowl"].each do |subject|
23
+ issues = Flux3AiPromptTools.validate({ "subject" => subject })
24
+ assert_equal ["control_character"], codes(issues), subject.inspect
25
+ end
26
+ end
27
+
28
+ def test_invalid_utf8_is_rejected
29
+ subject = "a bowl".b
30
+ subject.setbyte(0, 0xFF)
31
+ issues = Flux3AiPromptTools.validate({ "subject" => subject })
32
+ assert_equal ["encoding"], codes(issues)
33
+ end
34
+
35
+ def test_unicode_subject_is_measured_in_characters
36
+ subject = "一只红碗"
37
+ result = Flux3AiPromptTools.assemble({ "subject" => subject })
38
+ assert_equal "一只红碗.", result.prompt
39
+ assert_equal subject.length + 1, result.counts["prompt_chars"]
40
+ end
41
+
42
+ def test_string_length_limit
43
+ issues = Flux3AiPromptTools.validate({ "subject" => "a" * 401 })
44
+ assert_equal ["too_long"], codes(issues)
45
+ end
46
+
47
+ def test_unknown_and_duplicate_fields
48
+ issues = Flux3AiPromptTools.validate({ "subject" => "a bowl", "model" => "none", :subject => "other" })
49
+ assert_includes codes(issues), "unknown_field"
50
+ assert_includes codes(issues), "duplicate_field"
51
+ refute_includes JSON.generate(issues.map(&:to_h)), "endpoint"
52
+ end
53
+
54
+ def test_null_clears_a_preset_field
55
+ result = Flux3AiPromptTools.assemble(
56
+ { "preset" => "product_still", "subject" => "a bowl", "lighting" => nil }
57
+ )
58
+ refute_includes result.prompt, "Lighting:"
59
+ assert_includes result.prompt, "photograph."
60
+ end
61
+
62
+ def test_unknown_preset_and_layout
63
+ issues = Flux3AiPromptTools.validate({ "subject" => "a bowl", "preset" => "cinematic", "layout" => "poem" })
64
+ assert_equal %w[unknown_preset unknown_layout], codes(issues)
65
+ end
66
+
67
+ def test_detail_weights_and_bad_detail_shapes
68
+ result = Flux3AiPromptTools.assemble(
69
+ {
70
+ "subject" => "a bowl",
71
+ "details" => ["speckled glaze", { "text" => "wide rim", "weight" => 1.2 }]
72
+ }
73
+ )
74
+ assert_includes result.prompt, "speckled glaze"
75
+ assert_includes result.prompt, "wide rim (emphasis 1.2)"
76
+
77
+ issues = Flux3AiPromptTools.validate(
78
+ {
79
+ "subject" => "a bowl",
80
+ "details" => [{ "text" => "rim", "weight" => 2.01, "boost" => true }, ""]
81
+ }
82
+ )
83
+ assert_includes codes(issues), "range"
84
+ assert_includes codes(issues), "unknown_field"
85
+ assert_includes codes(issues), "blank"
86
+ end
87
+
88
+ def test_numeric_metadata_bounds
89
+ ok = Flux3AiPromptTools.assemble(
90
+ { "subject" => "a bowl", "seed" => 0, "steps" => 150, "guidance" => 3.5, "aspect_ratio" => "64:1" }
91
+ )
92
+ assert_equal 0, ok.parameters["seed"]
93
+ assert_equal 150, ok.parameters["steps"]
94
+ assert_in_delta 3.5, ok.parameters["guidance"], 0.0001
95
+ assert_equal "64:1", ok.parameters["aspect_ratio"]
96
+
97
+ bad = Flux3AiPromptTools.validate(
98
+ {
99
+ "subject" => "a bowl",
100
+ "seed" => 1.0,
101
+ "steps" => 0,
102
+ "guidance" => 30.01,
103
+ "aspect_ratio" => "65:1"
104
+ }
105
+ )
106
+ assert_equal %w[range type range range], codes(bad)
107
+ end
108
+
109
+ def test_seed_above_the_safe_json_integer_is_rejected
110
+ issues = Flux3AiPromptTools.validate({ "subject" => "a bowl", "seed" => Flux3AiPromptTools::Limits::MAX_SEED + 1 })
111
+ assert_equal ["range"], codes(issues)
112
+
113
+ result = Flux3AiPromptTools.assemble({ "subject" => "a bowl", "seed" => Flux3AiPromptTools::Limits::MAX_SEED })
114
+ assert_equal Flux3AiPromptTools::Limits::MAX_SEED, result.parameters["seed"]
115
+ end
116
+
117
+ def test_internal_spaces_are_preserved_and_terminal_punctuation_is_kept
118
+ result = Flux3AiPromptTools.assemble({ "subject" => "Watch the rim." })
119
+ assert_equal "Watch the rim.", result.prompt
120
+ spaced = Flux3AiPromptTools.assemble({ "subject" => "a red bowl" })
121
+ assert_equal "a red bowl.", spaced.prompt
122
+ end
123
+
124
+ def test_note_is_stored_and_not_rendered
125
+ result = Flux3AiPromptTools.assemble({ "subject" => "a bowl", "note" => "private reminder" })
126
+ assert_equal "private reminder", result.note
127
+ refute_includes result.prompt, "private"
128
+ refute_includes result.negative_prompt, "private"
129
+ end
130
+
131
+ def test_non_hash_record
132
+ issues = Flux3AiPromptTools.validate(["a bowl"])
133
+ assert_equal ["type"], codes(issues)
134
+ end
135
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flux3_ai_prompt_tools
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andy Wang
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-11-08 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ Offline Ruby library and command-line tool that validates structured prompt
15
+ specifications and assembles text-to-image prompts into JSON and JSONL
16
+ manifests. It does not generate images, call a model API, or send data
17
+ over the network. It is an independent utility by Andy Wang and is not a
18
+ Black Forest Labs product, an official FLUX client, or the flux_3_org gem.
19
+ email:
20
+ executables:
21
+ - flux3-prompt
22
+ extensions: []
23
+ extra_rdoc_files: []
24
+ files:
25
+ - CHANGELOG.md
26
+ - LICENSE
27
+ - README.md
28
+ - Rakefile
29
+ - bin/flux3-prompt
30
+ - examples/batch.jsonl
31
+ - examples/kettle.json
32
+ - flux3_ai_prompt_tools.gemspec
33
+ - lib/flux3_ai_prompt_tools.rb
34
+ - lib/flux3_ai_prompt_tools/assembler.rb
35
+ - lib/flux3_ai_prompt_tools/cli.rb
36
+ - lib/flux3_ai_prompt_tools/errors.rb
37
+ - lib/flux3_ai_prompt_tools/fields.rb
38
+ - lib/flux3_ai_prompt_tools/layouts.rb
39
+ - lib/flux3_ai_prompt_tools/limits.rb
40
+ - lib/flux3_ai_prompt_tools/manifest.rb
41
+ - lib/flux3_ai_prompt_tools/numbers.rb
42
+ - lib/flux3_ai_prompt_tools/presets.rb
43
+ - lib/flux3_ai_prompt_tools/reader.rb
44
+ - lib/flux3_ai_prompt_tools/schema.rb
45
+ - lib/flux3_ai_prompt_tools/specification.rb
46
+ - lib/flux3_ai_prompt_tools/validator.rb
47
+ - lib/flux3_ai_prompt_tools/version.rb
48
+ - test/assembler_test.rb
49
+ - test/cli_test.rb
50
+ - test/helper.rb
51
+ - test/manifest_test.rb
52
+ - test/packaging_test.rb
53
+ - test/reader_test.rb
54
+ - test/validator_test.rb
55
+ homepage: https://flux3-ai.com/
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ homepage_uri: https://flux3-ai.com/
60
+ allowed_push_host: https://rubygems.org
61
+ post_install_message:
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 3.1.0
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 3.4.20
77
+ signing_key:
78
+ specification_version: 4
79
+ summary: Validate structured inputs and assemble offline text-to-image prompt manifests.
80
+ test_files: []