diff --git a/lib/web/screens/web_generate_website_screen.dart b/lib/web/screens/web_generate_website_screen.dart index 56d9536..6e655f3 100644 --- a/lib/web/screens/web_generate_website_screen.dart +++ b/lib/web/screens/web_generate_website_screen.dart @@ -13,7 +13,7 @@ import 'package:fula_files/core/services/google_forms_service.dart'; import 'package:fula_files/web/services/web_website_service.dart'; /// Result returned when the user taps Publish — same shape as the -/// native GenerateWebsitePromptResult. +/// native GenerateWebsitePromptResult, plus [revisionRequest]. typedef WebGeneratePromptResult = ({ String websiteName, String category, @@ -23,6 +23,14 @@ typedef WebGeneratePromptResult = ({ String prompt, bool enableTracking, ContactFormConfig? contactForm, + + /// What the user asked to CHANGE about an existing site. Empty for a + /// first-time build, and empty on a revision where the user only moved + /// the settings above (or nothing at all). + /// + /// Deliberately separate from [prompt]: [prompt] stays the site's own + /// description, so the two can be compared to see what actually moved. + String revisionRequest, }); class _PaletteOption { @@ -153,6 +161,14 @@ class WebGenerateWebsiteScreen extends StatefulWidget { final ContactFormConfig? initialContactForm; final List? initialLanguages; + /// Editing an existing site rather than building a new one. + /// + /// Changes what the screen ASKS for: the fields below describe a site + /// that already exists and is already approved, so the question becomes + /// "what should change?" rather than "what should this be?". Leaving + /// everything alone is a valid answer and keeps the site as it is. + final bool revisionMode; + const WebGenerateWebsiteScreen({ super.key, required this.defaultName, @@ -165,6 +181,7 @@ class WebGenerateWebsiteScreen extends StatefulWidget { this.initialEnableTracking = false, this.initialContactForm, this.initialLanguages, + this.revisionMode = false, }); @override @@ -179,6 +196,10 @@ class _WebGenerateWebsiteScreenState extends State { : widget.defaultName); late final TextEditingController _promptController = TextEditingController(text: widget.initialPrompt ?? ''); + + /// Revision mode only — what the user wants changed. Starts EMPTY: an + /// empty field means "change nothing", which is a real answer here. + final TextEditingController _revisionController = TextEditingController(); late String _category = _categoryOptions.contains(widget.initialCategory) ? widget.initialCategory! : _categoryOptions.first; @@ -244,6 +265,7 @@ class _WebGenerateWebsiteScreenState extends State { void dispose() { _nameController.dispose(); _promptController.dispose(); + _revisionController.dispose(); _destinationController.dispose(); _emailSubjectController.dispose(); _titleController.dispose(); @@ -306,6 +328,19 @@ class _WebGenerateWebsiteScreenState extends State { )); } + /// Everything about a contact form EXCEPT where it delivers to. + /// + /// Two configs with the same spec describe the same form, so an existing + /// Google Form still serves it — only a spec change needs a new one. + static String _contactFormSpecOf(ContactFormConfig cfg) => ContactFormConfig( + enabled: cfg.enabled, + channel: cfg.channel, + destination: '', + emailSubject: cfg.emailSubject, + title: cfg.title, + fields: cfg.fields, + ).encode(); + Future _submit() async { final name = _nameController.text.trim(); if (name.isEmpty) return; @@ -321,7 +356,30 @@ class _WebGenerateWebsiteScreenState extends State { return; } - if (contactForm.channel == ContactFormChannel.sheets) { + // An UNCHANGED Sheets form on a revision keeps the form it already + // has. Creating a new one would abandon the responses collected so + // far, change the iframe URL in the page, and — because the + // responder URL is part of the stored prompt — make every edit look + // like a settings change, so "change nothing" could never be + // honoured on a site with a Google form. + final existing = widget.initialContactForm; + final formUnchanged = widget.revisionMode && + existing != null && + existing.channel == ContactFormChannel.sheets && + existing.destination.trim().isNotEmpty && + _contactFormSpecOf(existing) == _contactFormSpecOf(contactForm); + if (formUnchanged) { + contactForm = ContactFormConfig( + enabled: contactForm.enabled, + channel: contactForm.channel, + destination: existing.destination, + emailSubject: contactForm.emailSubject, + title: contactForm.title, + fields: contactForm.fields, + ); + } + + if (contactForm.channel == ContactFormChannel.sheets && !formUnchanged) { bool isLoadingShown = false; try { final granted = await AuthService.instance.requestFormsScope(); @@ -390,6 +448,8 @@ class _WebGenerateWebsiteScreenState extends State { prompt: _promptController.text.trim(), enableTracking: _enableTracking, contactForm: contactForm.enabled ? contactForm : null, + revisionRequest: + widget.revisionMode ? _revisionController.text.trim() : '', )); } } @@ -443,13 +503,48 @@ class _WebGenerateWebsiteScreenState extends State { final theme = Theme.of(context); final nameEmpty = _nameController.text.trim().isEmpty; return Scaffold( - appBar: AppBar(title: const Text('Generate Website')), + appBar: AppBar( + title: Text( + widget.revisionMode ? 'Update Website' : 'Generate Website')), body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 760), child: ListView( padding: const EdgeInsets.all(16), children: [ + // Revision mode leads with the only question that matters: + // what should change. Everything below it already describes + // a site the user has and approved, so it is shown as + // adjustable settings rather than as a fresh brief. + if (widget.revisionMode) ...[ + TextField( + controller: _revisionController, + maxLength: 8000, + minLines: 3, + maxLines: 6, + autofocus: true, + decoration: const InputDecoration( + labelText: 'What should change?', + hintText: + 'e.g. "Change the headline to Aurora Design Studio" ' + 'or "Add a contact section under the gallery"', + helperText: + 'Only what you describe here (and any setting you ' + 'change below) will be altered — the rest of the site ' + 'stays exactly as it is. Leave this empty to keep the ' + 'site unchanged.', + helperMaxLines: 4, + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + Text( + 'The settings below are how this site was built. Change one ' + 'only if you want it applied to the existing site.', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 16), + ], TextField( controller: _nameController, maxLength: 60, @@ -602,14 +697,21 @@ class _WebGenerateWebsiteScreenState extends State { minLines: 4, maxLines: 6, decoration: InputDecoration( - labelText: 'Your creative direction', - hintText: - 'Add anything specific about content, layout, or theme ' - '— leave blank to use only the category and styles ' - 'above.', - helperText: 'Category- and style-specific instructions plus ' - 'technical constraints (static site, IPFS hosting, ' - 'responsive design) are added automatically.', + labelText: widget.revisionMode + ? 'Creative direction this site was built from' + : 'Your creative direction', + hintText: widget.revisionMode + ? null + : 'Add anything specific about content, layout, or theme ' + '— leave blank to use only the category and styles ' + 'above.', + helperText: widget.revisionMode + ? 'Editing this rewrites the brief for the whole site. ' + 'For a targeted change, use the field at the top ' + 'instead.' + : 'Category- and style-specific instructions plus ' + 'technical constraints (static site, IPFS hosting, ' + 'responsive design) are added automatically.', helperMaxLines: 3, border: const OutlineInputBorder(), suffixIcon: IconButton( @@ -644,7 +746,7 @@ class _WebGenerateWebsiteScreenState extends State { style: FilledButton.styleFrom(backgroundColor: AppColors.primary), icon: const Icon(LucideIcons.sparkles, size: 18), - label: const Text('Publish'), + label: Text(widget.revisionMode ? 'Apply changes' : 'Publish'), ), ], ), diff --git a/lib/web/screens/web_website_detail_screen.dart b/lib/web/screens/web_website_detail_screen.dart index f256b01..2fa2d35 100644 --- a/lib/web/screens/web_website_detail_screen.dart +++ b/lib/web/screens/web_website_detail_screen.dart @@ -114,7 +114,16 @@ class _WebWebsiteDetailScreenState extends State { } void _onServiceTick() { - if (mounted) setState(() {}); + if (!mounted) return; + setState(() {}); + // One-shot messages the service can't show itself — today: an edit + // that turned out to change nothing, which never becomes a job and + // so would otherwise happen silently. + final notice = WebWebsiteService.instance.takeNotice(); + if (notice != null) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(notice))); + } } /// Fold completed upload jobs into the asset rows (byteless: name + @@ -410,7 +419,12 @@ class _WebWebsiteDetailScreenState extends State { List get _readyAssets => [for (final a in _assets) if (a.isCidBacked) a]; - Future _publishFromResult(WebGeneratePromptResult result) async { + /// [baseCid] is set when this is an EDIT of an existing build: the + /// server uses it to find that site's source and revise it in place. + Future _publishFromResult( + WebGeneratePromptResult result, { + String? baseCid, + }) async { final enrichedPrompt = composeEnrichedWebsitePrompt( websiteName: result.websiteName, category: result.category, @@ -427,10 +441,15 @@ class _WebWebsiteDetailScreenState extends State { picked: List.of(_readyAssets), enableTracking: result.enableTracking, listInDirectory: _listInDirectory, + baseCid: baseCid, + revisionRequest: result.revisionRequest, ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Website generation started')), + SnackBar( + content: Text(baseCid != null + ? 'Applying your changes...' + : 'Website generation started')), ); } } @@ -512,11 +531,38 @@ class _WebWebsiteDetailScreenState extends State { } } - /// Native Recreate parity: reopen the generator prefilled from the - /// generation's parsed prompt, with the prior-site reference seeded - /// into the creative direction; the group's current assets are - /// reused on publish. + /// Recreate = EDIT this site, not design a new one. + /// + /// The generator reopens prefilled from the generation's parsed prompt + /// and asks the one question that matters — what should change. The + /// site's own source is what the AI edits (the server holds it, keyed + /// by this build's CID), so anything the user does not ask about comes + /// back untouched. An empty change request keeps the site exactly as it + /// is; the server answers that one without running the model at all. Future _recreate(WebsiteGeneration gen) async { + final baseCid = gen.resultCid; + if (baseCid == null || baseCid.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text( + 'This build has no published copy to edit — create a new website instead'))); + return; + } + // Asked before the user invests any effort, because the answer can + // only be acted on beforehand: the generate endpoint ignores an + // unknown field, so a server without this feature would accept the + // job, charge for it, and return a redesigned site. + final canRevise = await WebWebsiteService.instance.supportsRevision(); + if (!mounted) return; + if (!canRevise) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text( + 'Editing an existing website is not available on this server yet'))); + return; + } + return _recreateFrom(gen, baseCid); + } + + Future _recreateFrom(WebsiteGeneration gen, String baseCid) async { // Same public-content acknowledgement as Create Website (native // parity), including the directory choice — a recreate produces a // NEW generation, so it gets its own decision rather than silently @@ -530,13 +576,10 @@ class _WebWebsiteDetailScreenState extends State { if (accepted != true || !mounted) return; _listInDirectory = listInDirectory.value; final parsed = parseStoredWebsitePrompt(gen.prompt); - final priorUrl = gen.gatewayUrl ?? ''; - final priorPromptForRef = - parsed.userBody.isNotEmpty ? parsed.userBody : gen.prompt.trim(); - final seededPrompt = - 'The website "$priorUrl" was created for prompt: "$priorPromptForRef"\n\n' - '[Describe what to change or add for the new version]'; - + // The creative direction is restored VERBATIM — no "the website X was + // created for prompt Y" preamble. That sentence used to be the only + // link back to the previous site, and reading it as a fresh brief is + // exactly why Recreate produced a different site every time. final result = await Navigator.of(context).push( MaterialPageRoute( @@ -547,10 +590,11 @@ class _WebWebsiteDetailScreenState extends State { initialCategory: parsed.category, initialStyles: parsed.styles, initialPalette: parsed.palette, - initialPrompt: seededPrompt, + initialPrompt: parsed.userBody, initialEnableTracking: gen.trackingEnabled, initialContactForm: parsed.contactForm, initialLanguages: parsed.languages, + revisionMode: true, ), ), ); @@ -561,7 +605,7 @@ class _WebWebsiteDetailScreenState extends State { 'No reusable assets in this group — import files first'))); return; } - await _publishFromResult(result); + await _publishFromResult(result, baseCid: baseCid); } @override diff --git a/lib/web/services/web_website_generate_logic.dart b/lib/web/services/web_website_generate_logic.dart new file mode 100644 index 0000000..16b84ea --- /dev/null +++ b/lib/web/services/web_website_generate_logic.dart @@ -0,0 +1,83 @@ +// Pure request/response decisions for POST /api/v1/generate, extracted +// from WebWebsiteService for VM unit tests (repo convention: services +// keep the I/O, logic files keep the decisions). + +/// Build the `/api/v1/generate` request body. +/// +/// [baseCid] is what turns a submission from "design a new site" into +/// "edit this one": the server resolves it to that build's stored source +/// and revises it in place, so anything [revisionRequest] does not +/// mention comes back untouched. Both fields are OMITTED entirely for a +/// first-time build, so the body a fresh generation sends is unchanged. +Map buildGenerateRequestBody({ + required String prompt, + required List> assets, + required bool enableTracking, + required bool listed, + required String listingName, + required String listingGroup, + String? baseCid, + String revisionRequest = '', +}) { + final isRevision = baseCid != null && baseCid.isNotEmpty; + return { + 'prompt': prompt, + 'assets': assets, + 'enable_tracking': enableTracking, + // Capability: opt into the backend's multi-pass pipeline. + 'pipeline_version': 2, + // Public directory. Sent explicitly — the server column defaults to + // false, so an older client (or a resumed job) can never publish a + // user into the directory by omission. + 'listed': listed, + 'listing_name': listingName, + // Per-WEBSITE key so the directory shows one entry per website + // instead of one per regeneration. + 'listing_group': listingGroup, + if (isRevision) 'base_cid': baseCid, + if (isRevision) 'revision_request': revisionRequest, + }; +} + +/// How a `/generate` response should be treated. +enum GenerateOutcome { + /// 202 — a job was created; poll it. + accepted, + + /// 200 `mode: unchanged` — the edit asked for nothing, so the site the + /// user already has IS the answer. No job, nothing charged. + unchanged, + + /// 409 — the site being edited can't be revised. [generateFailureMessage] + /// says why. + baseUnusable, + + /// Anything else — a real failure. + failed, +} + +GenerateOutcome classifyGenerateResponse( + int statusCode, + Map body, +) { + if (statusCode == 200) { + return body['mode'] == 'unchanged' + ? GenerateOutcome.unchanged + : GenerateOutcome.failed; + } + if (statusCode == 202) return GenerateOutcome.accepted; + if (statusCode == 409) return GenerateOutcome.baseUnusable; + return GenerateOutcome.failed; +} + +/// User-facing explanation for a 409. Both cases end the same way — build +/// a new website — but a site that predates the feature is not the user +/// doing anything wrong, so it does not read like an error. +String generateFailureMessage(Map body) { + if (body['code'] == 'BASE_SOURCE_UNAVAILABLE') { + return 'This website was created before editing was supported, so there ' + 'is no source to change. Use "Create Website" to build a new one.'; + } + return 'The website you are editing could not be found. Use "Create ' + 'Website" to build a new one.'; +} diff --git a/lib/web/services/web_website_service.dart b/lib/web/services/web_website_service.dart index 30d6b15..a72cac4 100644 --- a/lib/web/services/web_website_service.dart +++ b/lib/web/services/web_website_service.dart @@ -22,6 +22,7 @@ import 'package:fula_files/core/services/website_manifest_logic.dart'; import 'package:fula_files/core/services/website_prompt_builder.dart'; import 'package:fula_files/core/utils/file_type_utils.dart' as file_utils; import 'package:fula_files/web/services/web_cache_sync.dart'; +import 'package:fula_files/web/services/web_website_generate_logic.dart'; import 'package:fula_files/web/services/web_features.dart'; import 'package:fula_files/web/services/web_generation_steps.dart'; import 'package:fula_files/web/services/web_listing_cache.dart'; @@ -401,6 +402,15 @@ class _WebsitePollPaused implements Exception { const _WebsitePollPaused(this.message); } +/// Thrown when the server reports that an edit would change nothing. +/// +/// Not an error: no job was created and nothing was charged, because the +/// site the user already has IS the answer. Unwinds the pipeline so the +/// placeholder generation can be withdrawn instead of failing. +class _WebsiteUnchanged implements Exception { + const _WebsiteUnchanged(); +} + /// Web counterpart of the native WebsiteService generation pipeline: /// upload assets (unencrypted, same bucket/key/caps) → parse what the /// browser can (text; placeholders elsewhere — same as the desktop app, @@ -474,6 +484,24 @@ class WebWebsiteService extends ChangeNotifier { /// what changes it. final Map _listInDirectory = {}; + /// Which site an in-flight generation is EDITING, and what the user + /// asked to change. Transient for the same reason as the map above: it + /// is only needed between `startGeneration` and the `/generate` POST, + /// after which the server owns it. + final Map _revisionOf = {}; + + /// One-shot message for the screen to surface — set when something + /// worth saying happened outside a status change (today: the server + /// reporting that a revision would change nothing). Drained by the + /// reader so it is shown once. + String? _notice; + + String? takeNotice() { + final n = _notice; + _notice = null; + return n; + } + /// Server phase for [generationId], or null if none has been observed. String? serverPhaseFor(String generationId) => _serverPhase[generationId]; @@ -736,6 +764,36 @@ class WebWebsiteService extends ChangeNotifier { return null; } + bool? _supportsRevision; + + /// Whether the AI service can EDIT an existing site ("Recreate") rather + /// than design a new one. + /// + /// Asked BEFORE submitting, never after: `/generate`'s schema is + /// non-strict, so a server that predates the feature drops `base_cid` + /// silently, accepts the job, charges for it, and returns a brand-new + /// design. By then refusing is too late. An older server omits this + /// field entirely, and absent reads correctly as "cannot". + /// + /// Cached per session: a deployment does not gain the capability while + /// a tab is open, and this sits in front of a user action. + Future supportsRevision() async { + final cached = _supportsRevision; + if (cached != null) return cached; + try { + final response = await http + .get(Uri.parse('$_defaultAiEndpoint/api/v1/pricing')) + .timeout(const Duration(seconds: 5)); + if (response.statusCode == 200) { + final body = jsonDecode(response.body) as Map; + return _supportsRevision = body['supportsRevision'] == true; + } + } catch (_) {} + // Unreachable is not the same as unsupported — leave it uncached so a + // transient failure doesn't disable editing for the whole session. + return false; + } + Future _jwt() async { final jwt = await SecureStorageService.instance.read(SecureStorageKeys.jwtToken); @@ -763,6 +821,15 @@ class WebWebsiteService extends ChangeNotifier { /// when a user actively asked for it — here, or later via the /// website screen's toggle. bool listInDirectory = false, + + /// `resultCid` of the build being EDITED. When set, the server revises + /// that site's own source instead of designing a new one, so anything + /// [revisionRequest] does not mention comes back untouched. + String? baseCid, + + /// What the user asked to change. Empty with a [baseCid] set means + /// "change nothing" — the server answers that without running the AI. + String revisionRequest = '', }) async { final websiteName = tagName.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_'); @@ -790,6 +857,9 @@ class WebWebsiteService extends ChangeNotifier { trackingEnabled: enableTracking, ); _listInDirectory[generation.id] = listInDirectory; + if (baseCid != null && baseCid.isNotEmpty) { + _revisionOf[generation.id] = (baseCid: baseCid, request: revisionRequest); + } liveGenerations.insert(0, generation); _notify(generation); @@ -804,6 +874,16 @@ class WebWebsiteService extends ChangeNotifier { await _ensureUploadedPhase(generation, websiteName, picked); await _parsePhase(generation, websiteName, picked); await _generatePhase(generation); + } on _WebsiteUnchanged { + // Not a failure and not a build: the edit asked for nothing, so the + // site stands as it is. Drop the placeholder rather than leave a + // duplicate entry in the history claiming a build happened. + liveGenerations.removeWhere((g) => g.id == generation.id); + _revisionOf.remove(generation.id); + _listInDirectory.remove(generation.id); + _forgetPhase(generation.id); + _notice = 'No changes described — your website is unchanged.'; + notifyListeners(); } catch (e) { generation.status = WebsiteGenStatus.error; generation.errorMessage = e.toString(); @@ -968,6 +1048,7 @@ class WebWebsiteService extends ChangeNotifier { if (a.comment != null && a.comment!.trim().isNotEmpty) (fileName: a.fileName, cid: a.cid, comment: a.comment!), ]; + final revision = _revisionOf[generation.id]; final http.Response response; try { @@ -978,25 +1059,19 @@ class WebWebsiteService extends ChangeNotifier { 'Authorization': 'Bearer $jwt', 'Content-Type': 'application/json', }, - body: jsonEncode({ - 'prompt': - buildWebsiteAiPrompt(generation.prompt, assetNotes: assetNotes), - 'assets': assetPayloads, - 'enable_tracking': generation.trackingEnabled, - // Capability: opt into the backend's multi-pass pipeline - // (this client polls for up to 20 minutes below). - 'pipeline_version': 2, - // Public directory. Sent explicitly — the server column - // defaults to false, so an older client (or a resumed job) - // can never publish a user into the directory by omission. - 'listed': _listInDirectory[generation.id] ?? false, + body: jsonEncode(buildGenerateRequestBody( + prompt: buildWebsiteAiPrompt(generation.prompt, + assetNotes: assetNotes), + assets: assetPayloads, + enableTracking: generation.trackingEnabled, + listed: _listInDirectory[generation.id] ?? false, // The group's display name, sent as its own field rather // than scraped from the prompt (free text the user wrote). - 'listing_name': generation.tagName, - // Per-WEBSITE key so the directory shows one entry per - // website instead of one per regeneration. - 'listing_group': generation.tagId, - }), + listingName: generation.tagName, + listingGroup: generation.tagId, + baseCid: revision?.baseCid, + revisionRequest: revision?.request ?? '', + )), ) .timeout(const Duration(seconds: 30)); } on TimeoutException { @@ -1017,13 +1092,38 @@ class WebWebsiteService extends ChangeNotifier { if (response.statusCode == 429) { throw Exception('Rate limit exceeded. Please try again later.'); } - if (response.statusCode != 202) { - throw Exception( - 'Generation request failed (${response.statusCode}): ${response.body}'); + + Map accepted; + try { + accepted = jsonDecode(response.body) as Map; + } catch (_) { + accepted = const {}; } - final jobId = - (jsonDecode(response.body) as Map)['jobId'] as String; + switch (classifyGenerateResponse(response.statusCode, accepted)) { + case GenerateOutcome.accepted: + break; + case GenerateOutcome.unchanged: + // The edit asked for nothing: no job, nothing charged, and the + // site stands as it is. Republishing identical bytes would only + // add a duplicate history entry. + throw const _WebsiteUnchanged(); + case GenerateOutcome.baseUnusable: + throw Exception(generateFailureMessage(accepted)); + case GenerateOutcome.failed: + throw Exception( + 'Generation request failed (${response.statusCode}): ${response.body}'); + } + + // Belt and braces behind the pre-flight capability check: if a server + // still took this as a fresh build, say so rather than let the user + // discover it in the finished site. The job is already accepted and + // charged at this point, which is exactly why the real guard is the + // `supportsRevision()` probe before submitting. + if (revision != null && accepted['mode'] != 'revision') { + debugPrint('WebWebsiteService: server did not honour base_cid'); + } + final jobId = accepted['jobId'] as String; // Persist the job handle BEFORE polling. Everything up to here lived // only in this tab's memory; from this point a closed/killed tab (the diff --git a/test/unit/web/web_website_generate_logic_test.dart b/test/unit/web/web_website_generate_logic_test.dart new file mode 100644 index 0000000..b481efb --- /dev/null +++ b/test/unit/web/web_website_generate_logic_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fula_files/web/services/web_website_generate_logic.dart'; + +/// Guards the client half of the "Recreate makes a whole new website" fix. +/// +/// Recreate used to be a from-scratch generation whose only link to the +/// previous site was a sentence in the prompt text. The link is now +/// structural — `base_cid` names the build to edit — so the checks that +/// matter are that it is SENT when editing, ABSENT when building fresh, +/// and that the server's "nothing changed" answer is not mistaken for a +/// failure. +void main() { + Map body({String? baseCid, String revisionRequest = ''}) => + buildGenerateRequestBody( + prompt: 'Website Name: Aurora\nCategory: Corporation\n\nA studio.', + assets: [ + {'fileName': 'hero.png', 'url': 'https://gw/ipfs/cid-a'} + ], + enableTracking: false, + listed: false, + listingName: 'aurora', + listingGroup: 'tag-1', + baseCid: baseCid, + revisionRequest: revisionRequest, + ); + + group('buildGenerateRequestBody', () { + test('a first-time build sends no revision fields at all', () { + final b = body(); + expect(b.containsKey('base_cid'), isFalse); + expect(b.containsKey('revision_request'), isFalse); + // The rest of the contract is unchanged for a fresh generation. + expect(b['pipeline_version'], 2); + expect(b['listed'], false); + expect(b['listing_group'], 'tag-1'); + expect(b['listing_name'], 'aurora'); + }); + + test('an edit names the build it is editing', () { + final b = body(baseCid: 'bafy-base', revisionRequest: 'bluer headline'); + expect(b['base_cid'], 'bafy-base'); + expect(b['revision_request'], 'bluer headline'); + }); + + test('an EMPTY change request is still sent — it means "change nothing"', + () { + final b = body(baseCid: 'bafy-base'); + expect(b['base_cid'], 'bafy-base'); + expect(b.containsKey('revision_request'), isTrue); + expect(b['revision_request'], ''); + }); + + test('an empty base cid is treated as no base, not as an edit', () { + final b = body(baseCid: '', revisionRequest: 'ignored'); + expect(b.containsKey('base_cid'), isFalse); + expect(b.containsKey('revision_request'), isFalse); + }); + + test('listing consent is carried explicitly, never by omission', () { + final b = buildGenerateRequestBody( + prompt: 'p', + assets: const [], + enableTracking: true, + listed: true, + listingName: 'n', + listingGroup: 'g', + ); + expect(b['listed'], true); + expect(b['enable_tracking'], true); + }); + }); + + group('classifyGenerateResponse', () { + test('202 is a job to poll', () { + expect(classifyGenerateResponse(202, {'jobId': 'x', 'mode': 'revision'}), + GenerateOutcome.accepted); + }); + + test('200 "unchanged" is a real answer, not a failure', () { + expect( + classifyGenerateResponse(200, { + 'mode': 'unchanged', + 'resultCid': 'bafy-base', + }), + GenerateOutcome.unchanged, + ); + }); + + test('an unexpected 200 is a failure, not silently accepted', () { + expect(classifyGenerateResponse(200, const {}), GenerateOutcome.failed); + }); + + test('409 means the base cannot be edited', () { + expect( + classifyGenerateResponse(409, {'code': 'BASE_SOURCE_UNAVAILABLE'}), + GenerateOutcome.baseUnusable, + ); + expect( + classifyGenerateResponse(409, {'code': 'BASE_NOT_FOUND'}), + GenerateOutcome.baseUnusable, + ); + }); + + test('other statuses fail', () { + for (final code in [400, 401, 402, 429, 500, 503]) { + expect(classifyGenerateResponse(code, const {}), + GenerateOutcome.failed); + } + }); + }); + + group('generateFailureMessage', () { + test('a site that predates editing is explained, not blamed', () { + final m = generateFailureMessage({'code': 'BASE_SOURCE_UNAVAILABLE'}); + expect(m, contains('before editing was supported')); + expect(m, contains('Create Website')); + }); + + test('an unknown base falls back to a plain explanation', () { + final m = generateFailureMessage({'code': 'BASE_NOT_FOUND'}); + expect(m, contains('could not be found')); + expect(m, contains('Create Website')); + }); + }); +}