浏览代码

Add implementation plan for tabbed Category Manipulations page (v1.6.0)

Plan to fix the page-load timeout by splitting the monolithic
manipulations page into lazy-loaded tab partials, each fetching
its own data via a separate AJAX request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dalibor Votruba 1 月之前
父节点
当前提交
c893e8267a
共有 1 个文件被更改,包括 484 次插入0 次删除
  1. 484 0
      studiou-wc-product-cat-manage/implementation-plan-tabs.md

+ 484 - 0
studiou-wc-product-cat-manage/implementation-plan-tabs.md

@@ -0,0 +1,484 @@
+# Implementation Plan — Tabbed Category Manipulations Page (lazy partial loading)
+
+**Target version:** 1.6.0 (bump from current 1.5.3)
+**Scope:** Restructure the **Products → Category Manipulations** admin page so its 6 operation sections + the *Important Notes* block become **tabs**, where each tab's HTML (and its expensive data) is fetched via a separate AJAX "partial" request only when the tab is first opened.
+**Goal:** Eliminate the page-load timeout. Today `views/manipulations.php` runs three expensive data calls *before any HTML is sent*, so one slow page request must finish all of them. After this change the shell page renders instantly and each tab pays only for its own calculation, in its own request.
+
+---
+
+## 1. Why the page times out today
+
+`views/manipulations.php` runs all of the following synchronously on every page load, before output:
+
+```php
+$categories             = $manipulator->get_categories_with_counts();        // N+1 queries
+$media_categories       = $manipulator->get_media_categories_with_counts();   // N+1 queries
+$unassigned_media_count = $manipulator->get_unassigned_unused_media_count();  // heavy multi-table scan
+```
+
+- `get_categories_with_counts()` — loads every `product_cat` term, then runs **one count query per category** (`get_category_product_count()`). On a store with hundreds of categories this is hundreds of queries.
+- `get_media_categories_with_counts()` — same N+1 shape for the media-category taxonomy.
+- `get_unassigned_unused_media_count()` — calls `get_unassigned_unused_media_ids()`, which scans the whole `posts`/`postmeta` tables plus every `_product_image_gallery` row.
+
+All three must complete inside a single PHP request before the page renders. On a large store that single request exceeds `max_execution_time`. The fix is to (a) render the page shell with **zero** heavy calls, and (b) move each section's data fetch into its own on-demand AJAX request.
+
+---
+
+## 2. Target behaviour
+
+The page **Products → Category Manipulations** becomes:
+
+- A title, the existing notice area, and a horizontal **tab bar** (WordPress core `.nav-tab-wrapper` markup).
+- An empty content container `#studiou-wcpcm-tab-content`.
+- On load, the controller activates a tab (from the URL hash, else the first tab) and fetches its partial via AJAX.
+- Clicking a tab fetches that tab's partial **once**, then caches the rendered HTML in the DOM. Re-clicking a cached tab just shows it — no new request.
+- The active tab is reflected in the URL hash (`#move`, `#clear-media`, …) so a browser refresh — and the `location.reload()` calls that several operations already perform on success — return to the same tab and re-fetch its (now up-to-date) partial.
+
+Seven tabs, in this order:
+
+| Slug | Tab label (reuses existing translated heading) | Heavy data it needs |
+| --- | --- | --- |
+| `move` | Move products between categories | `get_categories_with_counts()` |
+| `batch-description` | Batch description between categories | `get_categories_with_counts()` |
+| `delete-products` | Delete products in category | `get_categories_with_counts()` |
+| `clear-media` | Clear Media Categories | `get_media_categories_with_counts()` |
+| `remove-unassigned` | Remove Not Assigned Media | `get_unassigned_unused_media_count()` |
+| `upvp` | Update product variant prices | `get_categories_with_counts()` |
+| `notes` | Important Notes | none (static) |
+
+Each tab request computes only *its own* data. A user who only needs the *Move* tab never pays for the media scans.
+
+---
+
+## 3. Files to create / modify
+
+### 3.1 New files
+
+| File | Purpose |
+| --- | --- |
+| `views/manipulations/tab-move.php` | Partial — "Move products between categories" card body |
+| `views/manipulations/tab-batch-description.php` | Partial — "Batch description between categories" card body |
+| `views/manipulations/tab-delete-products.php` | Partial — "Delete products in category" card body |
+| `views/manipulations/tab-clear-media.php` | Partial — "Clear Media Categories" card body |
+| `views/manipulations/tab-remove-unassigned.php` | Partial — "Remove Not Assigned Media" card body |
+| `views/manipulations/tab-upvp.php` | Partial — "Update product variant prices" card body |
+| `views/manipulations/tab-notes.php` | Partial — "Important Notes" card body |
+
+### 3.2 Modified files
+
+| File | Change |
+| --- | --- |
+| `studiou-wc-product-cat-manage.php` | Bump `Version:` header and `STUDIOU_WCPCM_VERSION` to `1.6.0`. Add 3 new `i18n` keys (`tabLoading`, `tabLoadError`, see §7). |
+| `includes/class-studiou-wc-product-cat-manage-manipulator.php` | Add `handle_load_tab_ajax()` + register the `studiou_wcpcm_load_tab` action. Add cached wrappers `get_categories_with_counts()` / `get_media_categories_with_counts()` + cache-invalidation hooks (see §5). |
+| `views/manipulations.php` | Replace the whole body with the lightweight shell (tab bar + empty container). **No heavy data calls.** |
+| `assets/css/admin.css` | Add styles for the tab content container loading state (tab bar itself uses WP core styles). |
+| `assets/js/admin.js` | Add the tab controller; convert per-tab `init*Form()` functions to be invoked *after* the partial is injected, via a slug→init registry. |
+| `languages/studiou-wc-product-cat-manage.pot` | Add new translation strings. |
+| `languages/studiou-wc-product-cat-manage-cs_CZ.po` | Add Czech translations. |
+| `languages/studiou-wc-product-cat-manage-cs_CZ.mo` | Regenerate (`msgfmt`). |
+| `readme.md` | Bump version, note the tabbed UI under *Features*, add **Version 1.6.0** changelog entry. |
+| `CLAUDE.md` | Bump *Current Version* to 1.6.0, document the tab partials, the new AJAX endpoint, the `views/manipulations/` folder. |
+| `directory-structure.txt` | Add the new `views/manipulations/` folder and its 7 partial files. |
+
+No existing AJAX handler, no existing operation logic, and no DB schema changes.
+
+---
+
+## 4. Shell page — `views/manipulations.php`
+
+Replace the entire current contents with a shell that does **no** `$manipulator` calls:
+
+```php
+<?php
+// If this file is called directly, abort.
+if (!defined('WPINC')) { die; }
+
+$tabs = array(
+    'move'              => __('Move products between categories', 'studiou-wc-product-cat-manage'),
+    'batch-description' => __('Batch description between categories', 'studiou-wc-product-cat-manage'),
+    'delete-products'   => __('Delete products in category', 'studiou-wc-product-cat-manage'),
+    'clear-media'       => __('Clear Media Categories', 'studiou-wc-product-cat-manage'),
+    'remove-unassigned' => __('Remove Not Assigned Media', 'studiou-wc-product-cat-manage'),
+    'upvp'              => __('Update product variant prices', 'studiou-wc-product-cat-manage'),
+    'notes'             => __('Important Notes', 'studiou-wc-product-cat-manage'),
+);
+?>
+<div class="wrap studiou-wcpcm-wrap">
+    <h1><?php echo esc_html__('Category Manipulations', 'studiou-wc-product-cat-manage'); ?></h1>
+
+    <div class="studiou-wcpcm-notice-area"></div>
+
+    <h2 class="nav-tab-wrapper studiou-wcpcm-tabs">
+        <?php foreach ($tabs as $slug => $label): ?>
+            <a href="#<?php echo esc_attr($slug); ?>"
+               class="nav-tab"
+               data-tab="<?php echo esc_attr($slug); ?>">
+                <?php echo esc_html($label); ?>
+            </a>
+        <?php endforeach; ?>
+    </h2>
+
+    <div id="studiou-wcpcm-tab-content" class="studiou-wcpcm-tab-content">
+        <div class="studiou-wcpcm-tab-loading">
+            <?php echo esc_html__('Loading...', 'studiou-wc-product-cat-manage'); ?>
+        </div>
+    </div>
+</div>
+```
+
+`render_manipulations_page()` in the main plugin file is unchanged — it still just `include`s this file. The expensive `$manipulator` calls are deleted from here entirely.
+
+---
+
+## 5. Backend — `class-studiou-wc-product-cat-manage-manipulator.php`
+
+### 5.1 Register the new AJAX action
+
+In `__construct()`, alongside the existing `add_action('wp_ajax_studiou_wcpcm_*', ...)` lines:
+
+```php
+add_action('wp_ajax_studiou_wcpcm_load_tab', array($this, 'handle_load_tab_ajax'));
+```
+
+### 5.2 New handler `handle_load_tab_ajax()`
+
+Follows the established handler pattern in this file (`ob` cleaning, `error_reporting(0)` save/restore, nonce, capability):
+
+```php
+public function handle_load_tab_ajax() {
+    while (ob_get_level()) { ob_end_clean(); }
+
+    if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) {
+        wp_send_json_error(array('message' => __('Security check failed', 'studiou-wc-product-cat-manage')));
+        return;
+    }
+    if (!current_user_can('manage_woocommerce')) {
+        wp_send_json_error(array('message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage')));
+        return;
+    }
+
+    $tab     = isset($_POST['tab']) ? sanitize_key($_POST['tab']) : '';
+    $allowed = array('move', 'batch-description', 'delete-products', 'clear-media', 'remove-unassigned', 'upvp', 'notes');
+
+    if (!in_array($tab, $allowed, true)) {
+        wp_send_json_error(array('message' => __('Unknown tab', 'studiou-wc-product-cat-manage')));
+        return;
+    }
+
+    $partial = STUDIOU_WCPCM_PLUGIN_DIR . 'views/manipulations/tab-' . $tab . '.php';
+    if (!file_exists($partial)) {
+        wp_send_json_error(array('message' => __('Tab content not found', 'studiou-wc-product-cat-manage')));
+        return;
+    }
+
+    // $manipulator is available to the partial; each partial fetches only the data it needs.
+    $manipulator = $this;
+
+    try {
+        ob_start();
+        include $partial;
+        $html = ob_get_clean();
+        wp_send_json_success(array('tab' => $tab, 'html' => $html));
+    } catch (Exception $e) {
+        if (ob_get_level()) { ob_end_clean(); }
+        error_log('STUDIOU WC TABS: tab "' . $tab . '" render failed: ' . $e->getMessage());
+        wp_send_json_error(array('message' => $e->getMessage()));
+    }
+}
+```
+
+The whitelist (`$allowed`) plus `sanitize_key()` plus the explicit `tab-<slug>.php` filename pattern means the `include` path can never be influenced into a directory traversal.
+
+### 5.3 Each partial is self-contained
+
+Each partial receives `$manipulator` (set to `$this` by the handler) and fetches only what it needs at the top, e.g. `views/manipulations/tab-move.php`:
+
+```php
+<?php
+if (!defined('WPINC')) { die; }
+/** @var Studiou_WC_Product_Cat_Manage_Manipulator $manipulator */
+$categories = $manipulator->get_categories_with_counts();
+?>
+<div class="studiou-wcpcm-card">
+    <h2><?php echo esc_html__('Move products between categories', 'studiou-wc-product-cat-manage'); ?></h2>
+    ... existing "Move" card markup, copied verbatim from current manipulations.php lines 30-81 ...
+</div>
+```
+
+Mapping of which existing markup moves into which partial (line numbers refer to the current `views/manipulations.php`):
+
+- `tab-move.php` — current lines 29–82 (Move card). Needs `$categories`.
+- `tab-batch-description.php` — lines 85–141. Needs `$categories`.
+- `tab-delete-products.php` — lines 144–203. Needs `$categories`.
+- `tab-clear-media.php` — lines 206–269 (keeps the `empty($media_categories)` fallback branch). Needs `$media_categories = $manipulator->get_media_categories_with_counts();`.
+- `tab-remove-unassigned.php` — lines 272–302. Needs `$unassigned_media_count = $manipulator->get_unassigned_unused_media_count();`.
+- `tab-upvp.php` — lines 305–390. Needs `$categories`.
+- `tab-notes.php` — lines 393–406 (static `<ul>`; no data call).
+
+The `.studiou-wcpcm-card` wrapper stays inside each partial so the existing CSS keeps applying unchanged.
+
+### 5.4 Cache the N+1 category counts (addresses the "calculations" cost)
+
+Four of the seven tabs (`move`, `batch-description`, `delete-products`, `upvp`) each call `get_categories_with_counts()`. With lazy loading that is now four *separate* requests, each re-running the full N+1. To stop repeated recomputation, wrap the two N+1 methods in a transient cache.
+
+Rename the current bodies to private `*_uncached()` methods and add caching public wrappers:
+
+```php
+public function get_categories_with_counts() {
+    $key  = 'studiou_wcpcm_cat_counts';
+    $data = get_transient($key);
+    if (false === $data) {
+        $data = $this->get_categories_with_counts_uncached();
+        set_transient($key, $data, 5 * MINUTE_IN_SECONDS);
+    }
+    return $data;
+}
+
+public function get_media_categories_with_counts() {
+    $key  = 'studiou_wcpcm_media_cat_counts';
+    $data = get_transient($key);
+    if (false === $data) {
+        $data = $this->get_media_categories_with_counts_uncached();
+        set_transient($key, $data, 5 * MINUTE_IN_SECONDS);
+    }
+    return $data;
+}
+```
+
+Invalidate both transients whenever categories or product/category relationships change. Register in `__construct()`:
+
+```php
+$bust = function() {
+    delete_transient('studiou_wcpcm_cat_counts');
+    delete_transient('studiou_wcpcm_media_cat_counts');
+};
+add_action('created_term',          $bust);
+add_action('edited_term',           $bust);
+add_action('delete_term',           $bust);
+add_action('set_object_terms',      $bust);   // product <-> category assignment changes
+```
+
+Also call the buster directly at the end of `move_products`, `delete_products`/`delete_products_batch`, `clear_media`/`clear_media_batch` so the counts shown after an operation's `location.reload()` are fresh even within the 5-minute window.
+
+The 5-minute TTL is a safety net only; the explicit invalidation keeps the numbers correct. The `get_unassigned_unused_media_count()` scan is left uncached — it is only triggered by its own single tab and there is no cheap, reliable invalidation signal for "media used anywhere".
+
+> Note: §5.4 caching is the secondary optimisation. The primary timeout fix is §4 + §6 — splitting one giant request into seven small on-demand ones. Caching can be implemented in the same release or deferred without affecting the timeout fix.
+
+---
+
+## 6. Frontend — `assets/js/admin.js`
+
+### 6.1 The problem with the current init model
+
+Today every form is initialised in `$(document).ready()` via `safeInit(name, selector, initFn)`. With lazy tabs, the manipulation forms do **not** exist in the DOM at `ready` time — they arrive later inside an AJAX partial. So their `init*Form()` functions must run *after* the partial is injected, not on `ready`.
+
+### 6.2 Tab controller
+
+Add a controller that owns tab switching, lazy fetch, caching, and post-inject init. Register it on `ready` only when the tab bar is present:
+
+```js
+safeInit('Manipulation tabs', '.studiou-wcpcm-tabs', initManipulationTabs);
+```
+
+```js
+// Maps a tab slug to the init function that wires its partial's handlers.
+var TAB_INIT = {
+    'move':              initMoveForm,
+    'batch-description': initBatchDescriptionForm,
+    'delete-products':   initDeleteProductsForm,
+    'clear-media':       initClearMediaForm,
+    'remove-unassigned': initRemoveUnassignedMediaForm,
+    'upvp':              initUpdateVariantPricesForm,
+    'notes':             null   // static, nothing to wire
+};
+
+function initManipulationTabs() {
+    var $tabs    = $('.studiou-wcpcm-tabs .nav-tab');
+    var $content = $('#studiou-wcpcm-tab-content');
+    var cache    = {};   // slug -> rendered HTML, populated on first load
+    var allowed  = $tabs.map(function() { return $(this).data('tab'); }).get();
+
+    function activate(slug) {
+        if (allowed.indexOf(slug) === -1) { slug = allowed[0]; }
+
+        $tabs.removeClass('nav-tab-active')
+             .filter('[data-tab="' + slug + '"]').addClass('nav-tab-active');
+
+        if (window.history && history.replaceState) {
+            history.replaceState(null, '', '#' + slug);
+        } else {
+            window.location.hash = slug;
+        }
+
+        if (cache[slug] !== undefined) {
+            $content.html(cache[slug]);
+            return;   // already loaded + already initialised; just show it
+        }
+
+        $content.html('<div class="studiou-wcpcm-tab-loading">' +
+            (studiouWcpcm.i18n.tabLoading || 'Loading...') + '</div>');
+
+        $.ajax({
+            url: studiouWcpcm.ajaxUrl,
+            type: 'POST',
+            data: { action: 'studiou_wcpcm_load_tab', nonce: studiouWcpcm.nonce, tab: slug },
+            success: function(response) {
+                if (response.success && response.data && response.data.html) {
+                    cache[slug] = response.data.html;
+                    $content.html(response.data.html);
+                    var initFn = TAB_INIT[slug];
+                    if (typeof initFn === 'function') {
+                        try { initFn(); }
+                        catch (e) { console.error('STUDIOU WC: tab init "' + slug + '" failed', e); }
+                    }
+                } else {
+                    $content.html('<div class="studiou-wcpcm-tab-error">' +
+                        ((response.data && response.data.message) ||
+                         studiouWcpcm.i18n.tabLoadError || 'Failed to load tab') + '</div>');
+                }
+            },
+            error: function(xhr, status, error) {
+                $content.html('<div class="studiou-wcpcm-tab-error">' +
+                    (studiouWcpcm.i18n.tabLoadError || 'Failed to load tab') + ': ' + error + '</div>');
+            }
+        });
+    }
+
+    $tabs.on('click', function(e) {
+        e.preventDefault();
+        activate($(this).data('tab'));
+    });
+
+    // Initial tab: URL hash if valid, else first tab.
+    var initial = (window.location.hash || '').replace(/^#/, '');
+    activate(allowed.indexOf(initial) !== -1 ? initial : allowed[0]);
+}
+```
+
+### 6.3 Changes to the existing init functions
+
+- **Remove** these lines from the `$(document).ready()` block (their forms no longer exist at `ready`):
+  `safeInit('Move form', ...)`, `safeInit('Batch description form', ...)`, `safeInit('Delete products form', ...)`, `safeInit('Clear media form', ...)`, `safeInit('Remove unassigned media form', ...)`, `safeInit('Update variant prices form', ...)`.
+- **Keep** the `safeInit` calls for `Import form`, `Export form`, `Sample download`, `Product import form`, `Product export form` — those live on other admin pages and still load normally.
+- The six `init*Form()` function bodies themselves are **unchanged** — they already query by ID/selector and bind handlers; they just get invoked from `TAB_INIT` after injection instead of on `ready`.
+- Each tab partial is fetched at most once and cached, so each `init*Form()` runs exactly once → no double-bound handlers. (If a tab were ever re-fetched, re-binding would duplicate handlers; the cache guarantees it is not. A defensive `.off()` before `.on()` inside each init is an optional hardening step.)
+- `initUpdateVariantPricesForm()` already calls `loadAttributes()` at the end of its body — that AJAX now fires when the UPVP tab is opened, which is the desired lazy behaviour.
+- Operations that currently call `location.reload()` on success (move, delete, clear-media, remove-unassigned) keep doing so. After reload the shell re-reads the URL hash and re-fetches that tab's partial, which now shows refreshed counts. No JS change needed for that path beyond the hash being set by `activate()`.
+- The `$(window).on('load', ...)` debug block that logs `Move form exists`, `UPVP form exists`, etc. will now log `false` for tab forms at window-load time. Update it to drop the stale per-form checks (or leave it — it is debug-only and harmless). Recommended: trim it to just the tab-bar presence check.
+
+---
+
+## 7. Translations / i18n
+
+New strings to add to the `i18n` map in `wp_localize_script('studiou-wcpcm-admin', 'studiouWcpcm', ...)` in `studiou-wc-product-cat-manage.php`:
+
+| Key | English | Czech |
+| --- | --- | --- |
+| `tabLoading` | Loading... | Načítání... |
+| `tabLoadError` | Failed to load tab | Nepodařilo se načíst záložku |
+
+New translatable strings introduced in PHP (shell + handler), to add to `.pot` / `cs_CZ.po`:
+
+| English | Czech |
+| --- | --- |
+| Loading... | Načítání... |
+| Unknown tab | Neznámá záložka |
+| Tab content not found | Obsah záložky nenalezen |
+
+All seven tab labels reuse strings that are **already** translated (they are the current section `<h2>` headings), so no new label translations are required. Recompile `cs_CZ.mo` with `msgfmt` after editing the `.po`.
+
+---
+
+## 8. CSS — `assets/css/admin.css`
+
+The tab bar uses WordPress core classes (`.nav-tab-wrapper`, `.nav-tab`, `.nav-tab-active`) which are already styled by `wp-admin`. Only add styling for the content container states:
+
+```css
+.studiou-wcpcm-tab-content {
+    margin-top: 20px;
+}
+
+.studiou-wcpcm-tab-loading,
+.studiou-wcpcm-tab-error {
+    padding: 40px;
+    text-align: center;
+    color: #666;
+    background: #fff;
+    border: 1px solid #ddd;
+    border-radius: 4px;
+}
+
+.studiou-wcpcm-tab-error {
+    color: #a00;
+}
+```
+
+The existing `.studiou-wcpcm-card` rules are reused untouched — each partial still wraps its body in a `.studiou-wcpcm-card`.
+
+---
+
+## 9. Documentation updates
+
+### 9.1 `CLAUDE.md`
+- Bump *Current Version* `1.5.3` → `1.6.0`.
+- Under *Views (views/)*, change the `manipulations.php` bullet to note it is now a lightweight shell, and add a `views/manipulations/` sub-folder bullet listing the 7 tab partials.
+- Under *AJAX Handlers → Category Operations*, add `studiou_wcpcm_load_tab` — renders a manipulations tab partial on demand (NEW in v1.6.0).
+- Under the manipulator class bullets, note the new transient-cached `get_categories_with_counts()` / `get_media_categories_with_counts()` wrappers.
+
+### 9.2 `readme.md`
+- Bump version to `1.6.0`.
+- Under *Features*, note the Category Manipulations page is now tabbed with lazy-loaded sections.
+- Add changelog entry:
+  ```
+  ### Version 1.6.0
+  - Category Manipulations page restructured into tabs
+    - Each tab's content + data is loaded on demand via a separate AJAX request
+    - Fixes page-load timeout on stores with many categories / media files
+    - Active tab is preserved in the URL hash across refreshes
+  - New AJAX handler: studiou_wcpcm_load_tab
+  - Category/media count queries are now transient-cached (5 min, invalidated on term changes)
+  ```
+
+### 9.3 `directory-structure.txt`
+- Add the `views/manipulations/` folder and its 7 `tab-*.php` files.
+
+---
+
+## 10. Step-by-step execution order
+
+1. Bump version constant + plugin header to `1.6.0`.
+2. Create the `views/manipulations/` folder and the 7 `tab-*.php` partials by moving each section's existing markup out of `views/manipulations.php` (see §5.3 line mapping). Add the `$manipulator` data-fetch line at the top of each non-static partial.
+3. Replace `views/manipulations.php` with the shell from §4.
+4. Add `handle_load_tab_ajax()` + register `studiou_wcpcm_load_tab` in the manipulator constructor.
+5. Add the transient-cached count wrappers + invalidation hooks (§5.4).
+6. Add `initManipulationTabs()` + the `TAB_INIT` registry to `admin.js`; remove the six obsolete `safeInit` calls from `$(document).ready()`; trim the window-load debug block.
+7. Extend `wp_localize_script` `i18n` with `tabLoading` / `tabLoadError`.
+8. Add the tab-content CSS rules.
+9. Update `.pot`, `cs_CZ.po`; regenerate `cs_CZ.mo`.
+10. Update `readme.md`, `CLAUDE.md`, `directory-structure.txt`.
+11. Manual smoke test — see §11.
+12. Hard-refresh in the browser (studiou.cz strips `?ver=` query strings, so the asset version bump alone will not bust the cache — instruct the user to hard-refresh).
+
+---
+
+## 11. Test plan
+
+1. **Shell loads instantly** — open Category Manipulations; the page renders immediately with the tab bar and a "Loading..." placeholder; no timeout even on a store with hundreds of categories.
+2. **First tab auto-loads** — with no URL hash, the *Move* tab fetches and renders; its category dropdowns populate.
+3. **Lazy loading** — open the *Clear Media Categories* tab; confirm the media-category scan runs only now (not on initial page load) — check via query monitor / debug log.
+4. **Tab caching** — switch away from a tab and back; no second AJAX request fires; the form state is preserved.
+5. **Each operation still works** — Move, Batch description, Delete products, Clear media, Remove unassigned media, UPVP — run each from inside its tab; handlers, spinners, progress bars, and confirmation dialogs behave as before.
+6. **Post-operation reload** — after a Move/Delete/Clear that calls `location.reload()`, the page returns to the same tab (via hash) and shows refreshed counts.
+7. **URL hash** — manually load `…&page=studiou-category-manipulations#upvp`; the UPVP tab opens directly. An invalid hash falls back to the first tab.
+8. **Count caching** — open *Move*, then *Delete products*, then *UPVP* in succession; the N+1 category-count query runs once, subsequent tabs read the transient. After editing a category, the next tab load recomputes.
+9. **Media fallback** — on a store with no media-category taxonomy, the *Clear Media Categories* tab still shows the graceful "install a media category plugin" message.
+10. **Security** — `studiou_wcpcm_load_tab` with a bad nonce → "Security check failed"; an unknown `tab` value → "Unknown tab"; a user without `manage_woocommerce` → permission error.
+11. **Path safety** — confirm a crafted `tab` param (e.g. `../../wp-config`) is rejected by the whitelist and never reaches `include`.
+12. **Translations** — switch the site to `cs_CZ`; tab labels and the "Loading..." / error strings render in Czech.
+13. **Other pages unaffected** — Import/Export Categories and Product Import/Export pages still work (their `safeInit` calls are untouched).
+
+---
+
+## 12. Rollback
+
+The change is structural but non-destructive — no DB schema changes, no data writes, all existing operation handlers and their logic are untouched. To roll back: restore the previous monolithic `views/manipulations.php`, delete the `views/manipulations/` folder, revert `admin.js` / `admin.css` / the manipulator class / the main plugin file to 1.5.3. The two new transients (`studiou_wcpcm_cat_counts`, `studiou_wcpcm_media_cat_counts`) are self-expiring and need no cleanup.