feat: post-write consistency hardening (Stage 2A) #5

Merged
mtkell merged 1 commits from feat/post-write-consistency into main 2026-04-11 20:13:36 +00:00
4 changed files with 223 additions and 53 deletions

View File

@ -2,16 +2,17 @@
## Current status ## Current status
- main baseline updated through: description-only-edit - main baseline updated through: category-only-edit
- current branch: feat/category-only-edit (implementation complete, pending merge) - main baseline commit: `8e7e4cb` (2026-04-11)
- next branch: feat/post-write-consistency - next branch: feat/post-write-consistency
- current stage: Stage 1 — Web application completion (Stage 1B complete) - current stage: Stage 2 — Web application operational hardening
## Slice tracker ## Slice tracker
### feat/description-only-edit ### feat/description-only-edit
- status: merged to main - status: merged to main
- commit: `cebac4c`
- inspection: complete - inspection: complete
- implementation: complete - implementation: complete
- tests: passed (212/212) - tests: passed (212/212)
@ -20,34 +21,22 @@
### feat/category-only-edit ### feat/category-only-edit
- status: implementation complete, pending merge to main - status: merged to main
- commit: `8e7e4cb`
- inspection: complete - inspection: complete
- implementation: complete - implementation: complete
- tests: passed (223/223 feature_wordpress, 5/5 kell_web dashboard) - tests: passed (223/223 feature_wordpress, 5/5 kell_web dashboard)
- analyze: passed (dart analyze — no issues found) - analyze: passed (dart analyze — no issues found)
- brief updated: yes - brief updated: yes
- changed files:
- `lib/src/domain/product_publishing_repository.dart` — added `updateProductCategory` contract
- `lib/src/application/update_product_category.dart` — new use case (created)
- `lib/src/application/product_publishing_controller.dart` — added `CategoryActionResult`, `updateCategory`, `lastCategoryResult`, `consumeCategoryResult`
- `lib/src/data/fake_product_publishing_repository.dart` — implemented `updateProductCategory`
- `lib/src/data/wordpress_product_publishing_repository.dart` — implemented `updateProductCategory` (WP API mapping)
- `lib/src/presentation/widgets/product_preview_panel.dart` — added inline category edit UI (`onCategoryChanged`, `_buildCategoryRow`)
- `lib/src/presentation/product_publishing_page.dart` — wired `UpdateProductCategory` use case and `onCategoryChanged` callback
- `lib/src/presentation/widgets/status_action_snack_bar.dart` — added `showCategoryActionSnackBar`
- `lib/feature_wordpress.dart` — barrel export for `update_product_category.dart`
- `test/product_publishing_controller_test.dart` — added `updateCategory` test group (5 tests)
- `test/fake_product_publishing_repository_test.dart` — added `updateProductCategory` test group (6 tests)
- `test/widgets/product_publishing_page_test.dart` — updated mock to include `updateProductCategory`
- `apps/kell_web/test/dashboard/application/dashboard_controller_test.dart` — updated mock to include `updateProductCategory`
- `docs/development/master_development_brief.md` — updated baseline, validation state, next branch
- `docs/development/build_execution_tracker.md` — this file
### feat/post-write-consistency ### feat/post-write-consistency
- status: queued (Stage 2A) - status: implemented — ready for review / merge
- inspection: pending - inspection: complete
- implementation: pending - implementation: complete
- tests: pending - files changed:
- analyze: pending - `feature_wordpress/lib/src/application/product_publishing_controller.dart` — added `_refreshSelection()` helper; wired into `load()` and all write methods to preserve/refresh `selectedDraft` by id after reload
- brief updated: no - `feature_wordpress/test/product_publishing_controller_test.dart` — added 11 post-write consistency tests covering selection preservation, field refresh (status/price/name/description/category), lastModified, filter/search/sort persistence after writes, item repositioning under active sort, and filter-exit auto-reselection
- tests: passed (234/234 feature_wordpress)
- analyze: passed (dart analyze — no issues found)
- brief updated: yes

View File

@ -78,7 +78,7 @@ Rules:
- Search/filter/sort refinement landed. - Search/filter/sort refinement landed.
- Name-only product edit landed. - Name-only product edit landed.
- ✅ Description-only product edit landed (Stage 1A complete — merged `feat/description-only-edit``main` at `cebac4c`, 2026-04-11). - ✅ Description-only product edit landed (Stage 1A complete — merged `feat/description-only-edit``main` at `cebac4c`, 2026-04-11).
- ✅ Category-only product edit implemented (Stage 1B — `feat/category-only-edit`, pending merge to `main`, 2026-04-11). - ✅ Category-only product edit landed (Stage 1B complete — merged `feat/category-only-edit``main` at `8e7e4cb`, 2026-04-11). Stage 1 complete.
### Current narrow edit capabilities on `main` ### Current narrow edit capabilities on `main`
@ -86,21 +86,28 @@ Rules:
- update product price only - update product price only
- update product name only - update product name only
- update product description only - update product description only
- update product category only _(on `feat/category-only-edit`, pending merge)_ - update product category only
### Latest known validation state on `feat/category-only-edit` ### Latest known validation state on `main`
- `dart analyze` clean - `dart analyze` clean
- `feature_wordpress` tests passing - `feature_wordpress` tests passing
- `kell_web` dashboard tests passing - `kell_web` dashboard tests passing
- latest reported count for `feature_wordpress`: `223/223 passed` - latest reported count for `feature_wordpress`: `223/223 passed`
- latest reported count for `kell_web` dashboard tests: `5/5 passed` - latest reported count for `kell_web` dashboard tests: `5/5 passed`
- baseline: branched from `main` at `cebac4c` (2026-04-11) - baseline commit: `8e7e4cb` (2026-04-11)
### Post-write consistency (Stage 2A) — on branch `feat/post-write-consistency`
- `dart analyze` clean
- `feature_wordpress` tests: `234/234 passed`
- controller `_refreshSelection()` preserves/refreshes selection after all writes
- 11 new post-write consistency tests added
### Next recommended branch ### Next recommended branch
**`feat/post-write-consistency`** — Stage 2A: Post-write consistency hardening. **`feat/publishing-ux-hardening`** — Stage 2B: Publishing workflow UX hardening.
Branch from `main` after merging `feat/category-only-edit`. This completes Stage 1 (controlled product editing) and moves to Stage 2 (operational hardening). Branch from `main` after merging `feat/post-write-consistency`. Stage 2A is complete on branch.
--- ---
@ -141,25 +148,10 @@ Use one branch per edit slice:
> Merged `feat/description-only-edit``main` at `cebac4c` (2026-04-11). > Merged `feat/description-only-edit``main` at `cebac4c` (2026-04-11).
> All artifacts delivered: repository contract, fake/WP repo implementations, use case, controller action/result handling, preview panel inline description edit UI, and targeted tests (212 total `feature_wordpress` tests passing). > All artifacts delivered: repository contract, fake/WP repo implementations, use case, controller action/result handling, preview panel inline description edit UI, and targeted tests (212 total `feature_wordpress` tests passing).
#### Stage 1B — Category-only product edit ← **NEXT** #### ~~Stage 1B — Category-only product edit~~ ✅ COMPLETE
##### Goal > Merged `feat/category-only-edit``main` at `8e7e4cb` (2026-04-11).
> All artifacts delivered: repository contract, fake/WP repo implementations, use case, controller action/result handling, preview panel inline category edit UI, snack bar feedback, and targeted tests (223 total `feature_wordpress` tests passing). Stage 1 complete.
Allow updating product category only.
##### Requirements
- Use existing category representation only.
- Do not create full taxonomy management.
- Keep write mapping narrow inside WP repository.
- Use a simple constrained UI.
- Reuse the established narrow single-field edit pattern.
##### Definition of done
- category can be updated through a controlled workflow
- no taxonomy subsystem added
- tests/analyze clean
--- ---

View File

@ -534,9 +534,10 @@ class ProductPublishingController extends ChangeNotifier {
drafts = result; drafts = result;
// Keep selection valid; clear if the selected draft is no longer visible. // Keep selection valid and refresh to latest values.
if (selectedDraft != null && !drafts.any((d) => d.id == selectedDraft!.id)) { if (selectedDraft != null) {
selectedDraft = null; final refreshed = drafts.where((d) => d.id == selectedDraft!.id).firstOrNull;
selectedDraft = refreshed;
} }
} }

View File

@ -829,6 +829,194 @@ void main() {
}); });
}); });
// Post-write consistency
group('post-write consistency', () {
test('selection preserved after status update', () async {
await controller.load();
// Select product 4 (draft).
controller.selectBySku('JG-BLU-004');
expect(controller.selectedDraft!.id, '4');
// Publish it.
await controller.updateStatus('4', PublishStatus.published);
// Selection should still point to the same product.
expect(controller.selectedDraft, isNotNull);
expect(controller.selectedDraft!.id, '4');
// And reflect the updated status.
expect(controller.selectedDraft!.status, PublishStatus.published);
});
test('selected draft reflects latest price after update', () async {
await controller.load();
controller.selectBySku('JG-BLU-004');
expect(controller.selectedDraft!.price, 8.50);
await controller.updatePrice('4', 12.00);
expect(controller.selectedDraft, isNotNull);
expect(controller.selectedDraft!.id, '4');
expect(controller.selectedDraft!.price, 12.00);
});
test('selected draft reflects latest name after update', () async {
await controller.load();
controller.selectBySku('JG-BLU-004');
expect(controller.selectedDraft!.name, 'Fabric Jar Gripper');
await controller.updateName('4', 'Premium Jar Gripper');
expect(controller.selectedDraft, isNotNull);
expect(controller.selectedDraft!.id, '4');
expect(controller.selectedDraft!.name, 'Premium Jar Gripper');
});
test('selected draft reflects latest description after update', () async {
await controller.load();
controller.selectBySku('JG-BLU-004');
final originalDesc = controller.selectedDraft!.description;
await controller.updateDescription('4', 'Completely new description.');
expect(controller.selectedDraft, isNotNull);
expect(controller.selectedDraft!.id, '4');
expect(controller.selectedDraft!.description, 'Completely new description.');
expect(controller.selectedDraft!.description, isNot(originalDesc));
});
test('selected draft reflects latest category after update', () async {
await controller.load();
controller.selectBySku('JG-BLU-004');
expect(controller.selectedDraft!.category, 'Kitchen Accessories');
await controller.updateCategory('4', 'Grippers');
expect(controller.selectedDraft, isNotNull);
expect(controller.selectedDraft!.id, '4');
expect(controller.selectedDraft!.category, 'Grippers');
});
test('lastModified updates after write', () async {
await controller.load();
controller.selectBySku('JG-BLU-004');
final originalModified = controller.selectedDraft!.lastModified;
await controller.updatePrice('4', 15.00);
expect(controller.selectedDraft, isNotNull);
expect(controller.selectedDraft!.lastModified.isAfter(originalModified), isTrue);
});
test('selection preserved with active filter after write within filter', () async {
await controller.load();
// Filter to drafts only.
controller.setFilter('draft');
expect(controller.drafts.length, 2);
// Select product 4 (a draft).
controller.selectBySku('JG-BLU-004');
expect(controller.selectedDraft!.id, '4');
// Update its price it stays a draft, so it stays in the filter.
await controller.updatePrice('4', 20.00);
expect(controller.selectedDraft, isNotNull);
expect(controller.selectedDraft!.id, '4');
expect(controller.selectedDraft!.price, 20.00);
expect(controller.activeFilter, 'draft');
});
test('selection moves when write causes item to leave active filter', () async {
await controller.load();
// Filter to drafts only.
controller.setFilter('draft');
expect(controller.drafts.length, 2);
// Select product 4 (a draft).
controller.selectBySku('JG-BLU-004');
expect(controller.selectedDraft!.id, '4');
// Publish it it leaves the 'draft' filter.
await controller.updateStatus('4', PublishStatus.published);
// The original selection left the filter; load() auto-selects the
// first remaining visible draft.
expect(controller.activeFilter, 'draft');
expect(controller.drafts.length, 1);
expect(controller.selectedDraft, isNotNull);
expect(controller.selectedDraft!.id, isNot('4'));
});
test('search persists after write', () async {
await controller.load();
controller.setSearchQuery('fabric');
expect(controller.drafts.length, 1);
expect(controller.drafts.first.id, '4');
controller.selectDraft(controller.drafts.first);
await controller.updatePrice('4', 25.00);
// Search should still be active.
expect(controller.searchQuery, 'fabric');
expect(controller.drafts.length, 1);
expect(controller.selectedDraft, isNotNull);
expect(controller.selectedDraft!.price, 25.00);
});
test('sort persists after write', () async {
await controller.load();
controller.setSort(ProductSortField.lastModified, ascending: false);
// Select the first item (most recently modified).
controller.selectDraft(controller.drafts.first);
// Update a different product's price — this changes its lastModified.
await controller.updatePrice('1', 99.00);
// Sort should still be lastModified descending.
expect(controller.activeSortField, ProductSortField.lastModified);
expect(controller.sortAscending, false);
// Product 1 should now be first (most recently modified).
expect(controller.drafts.first.id, '1');
});
test('item repositions under active sort after name change', () async {
await controller.load();
// Default sort: name ascending.
// Order: Citrus, Fabric, Floral, Ocean, Skillet, Sublimated
expect(controller.drafts.first.name, 'Citrus Coaster Set');
// Select Fabric Jar Gripper (id 4, currently 2nd).
controller.selectBySku('JG-BLU-004');
expect(controller.selectedDraft!.id, '4');
// Rename to 'Zzz Gripper' should move to end of list.
await controller.updateName('4', 'Zzz Gripper');
// Selection should still be on the same product.
expect(controller.selectedDraft, isNotNull);
expect(controller.selectedDraft!.id, '4');
expect(controller.selectedDraft!.name, 'Zzz Gripper');
// It should now be last in the sorted list.
expect(controller.drafts.last.id, '4');
});
});
group('disposed guard', () { group('disposed guard', () {
test('load does not notify after disposal', () async { test('load does not notify after disposal', () async {
// Start load, then immediately dispose. // Start load, then immediately dispose.