diff --git a/docs/review/170/README.md b/docs/review/170/README.md index b6cfae0..866767d 100644 --- a/docs/review/170/README.md +++ b/docs/review/170/README.md @@ -190,3 +190,18 @@ Admin assertions and 78 Preset/Repeat assertions. Historical #170 PNGs, result JSONs, captions and conflict observer assertions remain byte-for-byte unchanged. Only the current Admin source fingerprint is refreshed; original #170 browser coverage must still run in exact-head hosted Admin before release readiness. + +### #284 UI polish batch source refresh + +The Admin fingerprint now binds the presentation-only polish batch #273–#278 +(tables, filters, primary, progress, copy, chips) on `ui/batch-273-278`. +Exactly three files are touched: `class-free-admin.php`, `free-selection.js`, +`free-selection.css`. No planner math, no frozen decimal contract, no storage, +no handler/nonce/route change. The sole runtime-adjacent exception is two +additive progress integers (`progress_total`, `progress_processed`) that are +derived from existing durable counters and hidden on unknown/zero totals. + +Historical PNGs, result JSONs, captions and conflict observer assertions remain +byte-for-byte unchanged; refreshed hashes alone are not browser acceptance +evidence. Hosted Admin must rerun #170 in both browsers against the exact PR +head before acceptance. diff --git a/docs/review/170/proof.json b/docs/review/170/proof.json index 2aab69a..9946d7d 100644 --- a/docs/review/170/proof.json +++ b/docs/review/170/proof.json @@ -2,9 +2,9 @@ "schema": 1, "base_main": "bd66a79", "source_hashes": { - "class-free-admin.php": "438e3c9632fb8decb0532743850870d70c4b9b04a5f9abcd8c901b61b2d866b9", - "free-selection.js": "01a855e15865aca82f746f2802eb738b0b32694d370d05d2429fad2f7adbd854", - "free-selection.css": "1b57bf6b002995be559615af213ec51098c2d46081308c72145ddd42d5eb0668" + "class-free-admin.php": "85ec78ba49b79727d113ffc6f7ee720163691e16ca7b12f605ab4d498cb78b8e", + "free-selection.js": "19f60a7914b3b845e5adb9585f4b347e93e61d1ccd44d1e04b7601501e32e28e", + "free-selection.css": "77673d2620b0fea759ef4ffa168f175a8ff1ead605cf6cac96a76ac4f155b199" }, "screenshots": [ { diff --git a/docs/review/pr-284-shots/README.md b/docs/review/pr-284-shots/README.md new file mode 100644 index 0000000..9cfdd72 --- /dev/null +++ b/docs/review/pr-284-shots/README.md @@ -0,0 +1,106 @@ +# PR #284 — Review screenshots (batch 273–278) + +English admin, USD, no customer data. All PNGs < 1 MB. Fixtures live in `fixtures/` (static HTML + CSS, headless Chromium via `playwright-core` + `/usr/bin/google-chrome`). Capture script: `/tmp/capture-pr284.cjs` (reuses pattern from `/tmp/wl273verify/verify.cjs`). + +## Source recovery +Copied verified shots without re-encoding (byte-identical to source) and renamed with a `pr284-` prefix so the new `pr-284-shots/` directory does not collide with git-tracked `docs/review/169..256`: + +- `pr284-table-*-top/stuck.png` (10 files) ← `/tmp/wl273verify/shot-*.png` (43K–144K) +- `pr284-wl275-*.png` (6 files) ← `/tmp/opencode/wl275-shots/*.png` (53K–157K) + +Additional fixtures in this directory (`fixtures/fixture-*.html`) were rendered headlessly to cover filters / progress / chips. + +## Tables — Preview / Results / History at 1440 / 1024 / 782 / 375 + 200% zoom + +Fixture: `/tmp/wl273verify/fixture.html` (40 KB) — same fixture used by `/tmp/wl273verify/verify.cjs`, built by `/tmp/wl273verify/make-fixture.cjs`. It renders three tables on one page: + +- **Preview** — 5 columns (`Product | Regular before→after | Delta | Shoppers would pay after Apply | What will happen`), 40 rows including long-name wrapping, `Café 日本 '})], ...overrides}; + return {status: 'OK', label: 'In progress', summary: '1 changed · 2 remaining', notice: '', undo_label: 'Undo unavailable', undo_summary: '0 restored', undo_notice: '', next_url: '/next', resume_available: true, undo_available: false, resume_nonce: 'bound-resume', undo_nonce: null, undo_button: 'Continue Undo', poll: true, progress_total: 2, progress_processed: 1, undo_counts: {eligible: 3, pending: 2, applying: 0, undone: 1, conflict: 0, failed: 0, needs_review: 0}, rows: [row(4, {name: ''})], ...overrides}; } async function respond(h, index, data) { h.requests[index].resolve({ok: true, status: 200, json: async () => ({success: true, data})}); await flush(); } (async () => { @@ -107,8 +107,18 @@ async function respond(h, index, data) { h.requests[index].resolve({ok: true, st await respond(h, 0, snapshot()); assert.equal(h.nodes['[data-progress-summary]'].textContent, '1 changed · 2 remaining'); assert.equal(h.body.children[0].children[0].textContent, ''); - assert.equal(h.body.children[0].children[2].textContent, 'Reload to read the current price'); + assert.equal(h.body.children[0].children[2].textContent, 'Refresh to read the current price'); assert.match(h.nodes['[data-progress-connection]'].textContent, /Last successful refresh/); + // #276 slim bars: the two additive Apply ints drive width and aria-valuenow; + // Undo derives its totals from the existing undo_counts; text stays the truth. + assert.equal(h.nodes['[data-progress-bar]'].hidden, false); + assert.equal(h.nodes['[data-progress-bar]'].getAttribute('aria-valuemax'), '2'); + assert.equal(h.nodes['[data-progress-bar]'].getAttribute('aria-valuenow'), '1'); + assert.equal(h.nodes['[data-progress-fill]'].style.width, '50%'); + assert.equal(h.nodes['[data-progress-undo-bar]'].hidden, false); + assert.equal(h.nodes['[data-progress-undo-bar]'].getAttribute('aria-valuemax'), '3'); + assert.equal(h.nodes['[data-progress-undo-bar]'].getAttribute('aria-valuenow'), '1'); + assert.equal(h.nodes['[data-progress-undo-fill]'].style.width, '33%'); const button = h.nodes['[data-progress-resume]'].querySelector('button'); assert(button); assert.equal(button.type, 'submit'); const form = h.nodes['[data-progress-resume]'].children[0]; assert.equal(form.action, '/post'); @@ -126,6 +136,19 @@ async function respond(h, index, data) { h.requests[index].resolve({ok: true, st assert.equal(h.body.children[0].children[4].className, 'writeleash-attention'); assert.equal([...h.timers.values()].filter(x => x.delay === 5000).length, 0); assert.match(h.nodes['[data-progress-announcement]'].textContent, /1 uncertain/); assert.match(h.nodes['[data-progress-connection]'].textContent, /Automatic updates stopped; refresh manually/); + // #276 bar lifecycle: aria-valuenow follows polling to finished, and + // unknown or zero totals hide the track instead of guessing. + h = harness(); await respond(h, 0, snapshot({progress_total: 4, progress_processed: 1})); + assert.equal(h.nodes['[data-progress-bar]'].getAttribute('aria-valuenow'), '1'); + assert.equal(h.nodes['[data-progress-fill]'].style.width, '25%'); + h.timer(5000); await respond(h, 1, snapshot({progress_total: 4, progress_processed: 4})); + assert.equal(h.nodes['[data-progress-bar]'].hidden, false); + assert.equal(h.nodes['[data-progress-bar]'].getAttribute('aria-valuemax'), '4'); + assert.equal(h.nodes['[data-progress-bar]'].getAttribute('aria-valuenow'), '4'); + assert.equal(h.nodes['[data-progress-fill]'].style.width, '100%'); + h.timer(5000); await respond(h, 2, snapshot({progress_total: 0, progress_processed: 0, undo_counts: {}})); + assert.equal(h.nodes['[data-progress-bar]'].hidden, true); + assert.equal(h.nodes['[data-progress-undo-bar]'].hidden, true); // A focused outcome cell keeps its previous saved text and gets an explicit stale-detail message. h = harness(); await respond(h, 0, snapshot()); const cell = h.body.children[0].children[4], focusedSummary = new Element('SUMMARY'); cell.appendChild(focusedSummary); h.document.activeElement = focusedSummary; @@ -216,9 +239,9 @@ async function respond(h, index, data) { h.requests[index].resolve({ok: true, st await respond(h, 1, snapshot({label: 'New observation'})); await respond(h, 0, snapshot({label: 'Old delayed observation'})); assert.equal(h.nodes['[data-progress-label]'].textContent, 'New observation'); // Session refusal stops polling; repeated transport errors back off then stop. h = harness(); h.requests[0].resolve({ok: false, status: 403}); await flush(); - assert.match(h.nodes['[data-progress-connection]'].textContent, /expired.*Reload and sign in/); assert.equal(h.timers.size, 0); + assert.match(h.nodes['[data-progress-connection]'].textContent, /expired.*Sign in again/); assert.equal(h.timers.size, 0); h = harness(); h.requests[0].resolve({ok: false, status: 401}); await flush(); - assert.match(h.nodes['[data-progress-connection]'].textContent, /expired.*Reload and sign in/); assert.equal(h.timers.size, 0); + assert.match(h.nodes['[data-progress-connection]'].textContent, /expired.*Sign in again/); assert.equal(h.timers.size, 0); h = harness(); for (let i = 0; i < 4; i++) { h.requests[i].reject(new Error('network failed')); await flush(); if (i === 0) { assert.match(h.nodes['[data-progress-connection]'].textContent, /retrying shortly/); } if (i < 3) { h.timer([10000, 20000, 40000][i]); } } assert.match(h.nodes['[data-progress-connection]'].textContent, /stopped after repeated errors/); assert.equal(h.timers.size, 0); diff --git a/wordpress/tests/admin/progress-integration.php b/wordpress/tests/admin/progress-integration.php index 69b85d6..8e9db41 100644 --- a/wordpress/tests/admin/progress-integration.php +++ b/wordpress/tests/admin/progress-integration.php @@ -51,11 +51,17 @@ function wl204_read( array $post ): array { $user = wp_get_current_user(); $user->add_cap( 'manage_woocommerce', false ); wl204_assert( 'FORBIDDEN' === wl204_read( $post )['status'], 'revoked capability refused' ); $user->remove_cap( 'manage_woocommerce' ); wl204_assert( 'OK' === Free_Admin::process_approve( array( 'job' => $job['public_id'], '_wpnonce' => wp_create_nonce( Free_Admin::ACTION_APPROVE . '_' . $job['plan_id'] ) ), 'POST' )['status'], 'exact approval accepted' ); +wl204_assert( str_contains( Free_Admin::reason_message( 'approved' ), 'Progress updates automatically' ), 'approved notice promises automatic updates' ); $before = wl204_read( wl204_post( $job ) ); Job_Worker::run( (int) $job['id'], array( 'max_items' => 1, 'budget_seconds' => 20 ), true ); $partial = wl204_read( wl204_post( $job ) ); wl204_assert( $partial['counts']['applied'] > $before['counts']['applied'] && $partial['counts']['pending'] > 0, 'Apply advances from real saved worker outcomes' ); wl204_assert( $partial['counts'] === Job_Repository::counts( (int) $job['id'] ), 'polled Apply counters equal durable rows' ); +// #276 slim bar: two additive ints mirror the durable counters; no existing field renamed. +wl204_assert( is_int( $partial['progress_total'] ) && is_int( $partial['progress_processed'] ), 'bar ints are integers' ); +wl204_assert( $partial['progress_total'] === $partial['counts']['planned'], 'bar total mirrors durable planned' ); +wl204_assert( $partial['progress_processed'] === $partial['counts']['planned'] - $partial['counts']['pending'] - $partial['counts']['applying'], 'bar processed mirrors durable counters' ); +wl204_assert( $partial['progress_processed'] > 0 && $partial['progress_processed'] < $partial['progress_total'], 'mid-job bar is partial' ); // A newer supported edit is never described as success. $product = wc_get_product( $ids[2] ); $product->set_regular_price( '99.00' ); $product->save(); for ( $n = 0; $n < 5; ++$n ) { Job_Worker::run( (int) $job['id'], array( 'max_items' => 3, 'budget_seconds' => 20 ), true ); } @@ -64,6 +70,12 @@ function wl204_read( array $post ): array { ob_start(); Free_Admin::render_view( 'job', $job['public_id'], 0 ); $html = ob_get_clean(); wl204_assert( str_contains( $html, 'Refresh saved progress' ) && str_contains( $html, 'Automatic updates require JavaScript' ), 'manual/no-JS refresh visible' ); wl204_assert( ! str_contains( $html, 'value="' . Free_Admin::ACTION_RESUME . '"' ), 'terminal no-JS view has no Resume form' ); +// #276 slim bars server-render from saved counters for Apply and Undo, with +// value semantics for assistive technology; no Reload copy remains in view. +wl204_assert( str_contains( $html, 'role="progressbar"' ) && str_contains( $html, 'data-progress-bar' ) && str_contains( $html, 'data-progress-undo-bar' ), 'Apply and Undo bars server-rendered' ); +wl204_assert( str_contains( $html, 'aria-valuemin="0"' ) && str_contains( $html, 'aria-valuenow' ) && str_contains( $html, 'aria-valuemax' ), 'bars carry value semantics' ); +wl204_assert( str_contains( $html, 'Apply progress' ) && str_contains( $html, 'Undo progress' ), 'bars carry labels' ); +wl204_assert( ! str_contains( $html, 'Reload' ), 'progress view carries no Reload copy' ); $started = Undo_Repository::initiate( (int) $job['id'], $actor ); $operation = Undo_Repository::read_operation_by_job( (int) $job['id'] ); $undo_before = wl204_read( wl204_post( $job ) ); @@ -73,6 +85,7 @@ function wl204_read( array $post ): array { wl204_assert( $undo_partial['poll'], 'unfinished Undo continues polling after terminal Apply' ); for ( $n = 0; $n < 5; ++$n ) { Undo_Worker::run( (int) $operation['id'], array( 'max_items' => 3, 'budget_seconds' => 20 ), true ); } $undo_terminal = wl204_read( wl204_post( $job ) ); wl204_assert( ! $undo_terminal['poll'] && ! $undo_terminal['undo_available'], 'terminal Undo stops polling and hides action' ); +wl204_assert( $undo_terminal['undo_counts']['eligible'] > 0 && 0 === $undo_terminal['undo_counts']['pending'] + $undo_terminal['undo_counts']['applying'], 'terminal Undo counters fully processed for the bar' ); // Missing item evidence must fail visibly without certifying successful completion. global $wpdb; $table = Job_Schema::items_table( $wpdb ); diff --git a/wordpress/tests/admin/progress-unit.php b/wordpress/tests/admin/progress-unit.php index 06ddc94..67ee91f 100644 --- a/wordpress/tests/admin/progress-unit.php +++ b/wordpress/tests/admin/progress-unit.php @@ -108,6 +108,19 @@ function refused204() { return array( 'status' => 'FORBIDDEN', 'reason' => 'prog $result = Admin::progress_snapshot( $post, 'POST' ); check204( $result['status'], 'OK', 'saved running fixture' ); check204( $result['poll'], true, 'running Apply polls' ); check204( $result['rows'][0]['apply_attention'], true, 'conflict not success' ); check204( $result['counts'], Jobs::$counts, 'durable counters authority' ); + // #276 slim bar: exactly two additive ints ride the payload; every + // existing field keeps its name and meaning for older clients. + check204( array_key_exists( 'progress_total', $result ) && array_key_exists( 'progress_processed', $result ), true, 'two additive bar ints present' ); + check204( is_int( $result['progress_total'] ) && is_int( $result['progress_processed'] ), true, 'bar ints are integers' ); + check204( $result['progress_total'], 2, 'Apply planned total' ); + check204( $result['progress_processed'], 1, 'Apply processed total' ); + check204( Admin::progress_bar_values( array( 'planned' => 4, 'pending' => 1, 'applying' => 1 ) ), array( 4, 2 ), 'bar mid-job values' ); + check204( Admin::progress_bar_values( array( 'planned' => 2, 'pending' => 0, 'applying' => 0 ) ), array( 2, 2 ), 'bar finished values' ); + check204( Admin::progress_bar_values( array( 'planned' => 0 ) ), array( 0, 0 ), 'bar zero total hides' ); + check204( Admin::progress_bar_values( array() ), array( 0, 0 ), 'bar unknown total hides' ); + check204( Admin::undo_progress_bar_values( array( 'eligible' => 3, 'pending' => 2, 'applying' => 0, 'undone' => 1, 'conflict' => 0, 'failed' => 0, 'needs_review' => 0 ) ), array( 3, 1 ), 'undo bar mid-job values' ); + check204( Admin::undo_progress_bar_values( array( 'eligible' => 2, 'pending' => 0, 'applying' => 0, 'undone' => 2, 'conflict' => 0, 'failed' => 0, 'needs_review' => 0 ) ), array( 2, 2 ), 'undo bar finished values' ); + check204( Admin::undo_progress_bar_values( array() ), array( 0, 0 ), 'undo bar unknown total hides' ); // Round-4 defect: the polled row text must carry the exact conflict guidance // the server-rendered table shows, never the short reason-message variant. $long_apply = 'This product’s price or other conditions changed after you reviewed the preview. WriteLeash left the newer value unchanged.'; @@ -144,7 +157,7 @@ function refused204() { return array( 'status' => 'FORBIDDEN', 'reason' => 'prog check204( Admin::progress_snapshot( array_merge( $post, array( 'offset' => '1000' ) ), 'POST' )['status'], 'OK', 'offset at the evidence bound accepted' ); Jobs::$stalled = true; $stalled = Admin::progress_snapshot( $post, 'POST' ); check204( $stalled['poll'], false, 'stalled stops' ); check204( str_contains( $stalled['notice'], 'stopped' ), true, 'stalled honest' ); Jobs::$stalled = false; Jobs::$state = 'COMPLETED'; Jobs::$counts['pending'] = 0; Jobs::$counts['applied'] = 2; - $terminal = Admin::progress_snapshot( $post, 'POST' ); check204( $terminal['poll'], false, 'terminal Apply stops' ); check204( $terminal['resume_available'], false, 'terminal cannot resume' ); check204( $terminal['resume_nonce'], null, 'terminal cannot obtain Resume nonce' ); check204( $terminal['undo_available'], true, 'eligible Undo becomes available' ); + $terminal = Admin::progress_snapshot( $post, 'POST' ); check204( $terminal['poll'], false, 'terminal Apply stops' ); check204( $terminal['resume_available'], false, 'terminal cannot resume' ); check204( $terminal['resume_nonce'], null, 'terminal cannot obtain Resume nonce' ); check204( $terminal['undo_available'], true, 'eligible Undo becomes available' ); check204( array( $terminal['progress_total'], $terminal['progress_processed'] ), array( 2, 2 ), 'terminal bar reads full' ); Undo::$state = 'UNDO_RUNNING'; $running = Admin::progress_snapshot( $post, 'POST' ); check204( $running['poll'], true, 'running Undo after Apply polls' ); check204( $running['undo_counts']['undone'], 1, 'Undo durable counts' ); Undo::$stalled = true; check204( Admin::progress_snapshot( $post, 'POST' )['poll'], false, 'stalled Undo stops' ); Undo::$stalled = false; Jobs::$counts['planned'] = 1; $missing = Admin::progress_snapshot( $post, 'POST' ); check204( $missing['poll'], false, 'missing Apply evidence blocks Undo polling too' ); check204( $missing['undo_available'], false, 'missing blocks Undo action' ); check204( $missing['resume_nonce'], null, 'missing blocks Resume nonce' ); check204( $missing['undo_nonce'], null, 'missing blocks Undo nonce' ); check204( str_contains( $missing['summary'], 'unavailable' ), true, 'missing evidence visibly incomplete' ); diff --git a/wordpress/tests/admin/recovery-browser.cjs b/wordpress/tests/admin/recovery-browser.cjs index e0d7a1b..6d5b623 100644 --- a/wordpress/tests/admin/recovery-browser.cjs +++ b/wordpress/tests/admin/recovery-browser.cjs @@ -71,9 +71,9 @@ function observe(mode = 'observe') { const plan = JSON.parse(observed.jobs[1].plan_json); eq(plan.items[0].planned_regular_price, '132.00', 'current price arithmetic'); eq(plan.source_job, f.job, 'provenance'); eq(plan.resolved_product_ids, [f.id], 'exact chosen population'); eq(plan.policy_snapshot.block_zero, false, 'edited safety setting frozen'); - eq(await page.getByRole('button', { name: 'Approve and apply', exact: true }).count(), 1, 'normal explicit approval'); + eq(await page.getByRole('button', { name: 'Approve and apply', exact: true }).count(), 2, 'normal explicit approval'); if (process.env.WL167_EVIDENCE) await page.screenshot({ path: process.env.WL167_EVIDENCE + '/205-preview-' + js + '.png', fullPage: true }); - await Promise.all([page.waitForURL('**wl_view=job**'), page.getByRole('button', { name: 'Approve and apply', exact: true }).click()]); + await Promise.all([page.waitForURL('**wl_view=job**'), page.getByRole('button', { name: 'Approve and apply', exact: true }).first().click()]); const applied = observe('run'); eq(applied.price, '132', 'normal Apply'); eq(applied.source, original.source, 'source unchanged after Apply'); await page.reload(); eq(await page.locator('tr[data-product-id="' + f.id + '"] td').nth(4).locator('strong').innerText(), 'Changed', 'result visible'); await context.close(); diff --git a/wordpress/tests/admin/regression-browser.cjs b/wordpress/tests/admin/regression-browser.cjs index bd81116..77b6ae8 100644 --- a/wordpress/tests/admin/regression-browser.cjs +++ b/wordpress/tests/admin/regression-browser.cjs @@ -75,7 +75,9 @@ async function search(page, term) { } } async function form(page, action) { - return page.locator('form').filter({ has: page.locator('input[name="action"][value="' + action + '"]') }).evaluate(el => Object.fromEntries(new FormData(el))); + // #275 polish renders Approve top+bottom (two byte-identical forms, same POST); + // disambiguate to first — both carry the same action/job/nonce fields. + return page.locator('form').filter({ has: page.locator('input[name="action"][value="' + action + '"]') }).first().evaluate(el => Object.fromEntries(new FormData(el))); } async function scan(page, name) { await page.addScriptTag({ path: require.resolve('axe-core/axe.min.js') }); @@ -240,7 +242,7 @@ async function responsive(page) { await route.fetch({ maxRedirects: 0 }); // Real server approval commits; browser receives no response. await route.abort('failed'); approvalForwarded(); }); - await tabTo(page, button(page, 'Approve and apply')); await page.keyboard.press('Enter'); + await tabTo(page, button(page, 'Approve and apply').first()); await page.keyboard.press('Enter'); await forwarded; await page.unroute('**/admin-post.php'); const jobURL = home + '&wl_view=job&wl_job=' + publicId; diff --git a/wordpress/tests/admin/selection-browser.cjs b/wordpress/tests/admin/selection-browser.cjs index b57bc5d..53db1d2 100644 --- a/wordpress/tests/admin/selection-browser.cjs +++ b/wordpress/tests/admin/selection-browser.cjs @@ -37,9 +37,10 @@ async function action(page, name) { // Server-side form submission; the heavy multi-engine suite can queue the // request behind other work, so allow a longer budget than the default. try { + // #275 polish batch renders Approve top+bottom (both required no-JS); disambiguate to first when duplicated. await Promise.all([ page.waitForEvent('framenavigated', { predicate: frame => frame === page.mainFrame(), timeout: 60000 }), - page.getByRole('button', { name, exact: true }).click() + (name === 'Approve and apply' ? page.getByRole('button', { name, exact: true }).first() : page.getByRole('button', { name, exact: true })).click() ]); } catch (error) { await capture(page, 'action-timeout-' + name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()); @@ -81,7 +82,7 @@ async function presets230(page, suffix) { await action(page, 'Load preset'); await page.locator('#writeleash-free-amount').fill('70'); await action(page, 'Preview price changes'); - ok(await page.getByRole('button', { name: 'Approve and apply', exact: true }).count() === 1, '#230 fresh Preview needs independent approval ' + suffix); + ok(await page.getByRole('button', { name: 'Approve and apply', exact: true }).count() === 2, '#230 fresh Preview needs independent approval (top + bottom) ' + suffix); ok(observe().saves === before.saves, '#230 Preview still performs no Woo saves ' + suffix); const preview = observe().jobs[0]; assert.deepEqual(JSON.parse(preview.plan_json).resolved_product_ids, expected, '#230 persisted range freezes exact current membership'); diff --git a/wordpress/tests/admin/selection-http.php b/wordpress/tests/admin/selection-http.php index 9407a31..f6b10fc 100644 --- a/wordpress/tests/admin/selection-http.php +++ b/wordpress/tests/admin/selection-http.php @@ -37,8 +37,9 @@ $http_original_rows167 = $http_rows167( $http_saved167['body'] ); beq( count( $http_original_rows167 ), 2, 'saved picker preview renders two product rows' ); foreach ( $http_original_rows167 as $row ) { - beq( $row[1], '$100.00 USD', 'saved expected price after prior Undo' ); - beq( $row[2], '$80.00 USD', 'saved absolute target' ); + // #273 polish merges Preview before→after into one cell; Delta/Change % moved to the next cell. + beq( $row[1], '$100.00 USD→ $80.00 USD', 'saved before-to-after merged cell after prior Undo' ); + bok( str_contains( $row[1], '$80.00 USD' ), 'saved absolute target in merged cell' ); } login_session(); $http_recent167 = admin_get( $bulk_url ); diff --git a/wordpress/writeleash/includes/free/class-free-admin.php b/wordpress/writeleash/includes/free/class-free-admin.php index eb28131..e845972 100644 --- a/wordpress/writeleash/includes/free/class-free-admin.php +++ b/wordpress/writeleash/includes/free/class-free-admin.php @@ -299,6 +299,7 @@ public static function progress_snapshot( array $post, string $method ): array { $active = $complete && ! $observed['stalled'] && in_array( $effective, array( Job_State::READY, Job_State::QUEUED, Job_State::RUNNING ), true ); $undo_active = $complete && null !== $undo_observed && ! $undo_observed['stalled'] && Undo_State::can_auto_run( $undo['operation_status'] ); $undo_summary = self::undo_summary( $undo ); + list( $progress_total, $progress_processed ) = self::progress_bar_values( $counts ); return array( 'status' => 'OK', 'counts' => $counts, 'undo_counts' => $undo, 'label' => $complete ? self::job_label( $effective ) : __( 'Saved product outcomes unavailable; needs checking', 'writeleash' ), @@ -311,6 +312,7 @@ public static function progress_snapshot( array $post, string $method ): array { 'resume_nonce' => $complete && Job_State::can_manual_run( $job['status'] ) ? wp_create_nonce( self::ACTION_RESUME . '_' . $public_id ) : null, 'undo_nonce' => $complete && $history['undo_eligible'] && ( empty( $undo['operation_status'] ) || Undo_State::can_manual_run( $undo['operation_status'] ) ) ? wp_create_nonce( self::ACTION_UNDO . '_' . $public_id ) : null, 'undo_button' => empty( $undo['operation_status'] ) ? __( 'Restore eligible prices (Undo)', 'writeleash' ) : __( 'Continue Undo', 'writeleash' ), + 'progress_total' => $progress_total, 'progress_processed' => $progress_processed, 'poll' => $active || $undo_active, 'rows' => $rows, 'next_url' => null === $page['next_offset'] ? null : self::page_url( 'job', $public_id, (int) $page['next_offset'], $filter ), ); } catch ( \Throwable $error ) { return array( 'status' => 'UNAVAILABLE', 'reason' => 'saved_progress_unavailable' ); } @@ -1325,13 +1327,13 @@ public static function reason_message( string $reason, ?int $selected = null ): 'invalid_preset_name' => __( 'Use a nonempty preset name of at most 100 bytes without markup or control characters.', 'writeleash' ), 'preset_limit' => __( 'This store has reached its limit of 20 presets. Delete a preset before saving another.', 'writeleash' ), 'preset_unavailable' => __( 'This preset is unavailable to your account.', 'writeleash' ), - 'preset_changed' => __( 'This preset changed during your request. Reload and try again.', 'writeleash' ), + 'preset_changed' => __( 'This preset changed during your request. Refresh and try again.', 'writeleash' ), 'preset_saved' => __( 'Preset saved. No prices changed; prepare a fresh Preview before applying.', 'writeleash' ), 'preset_loaded' => __( 'Preset loaded into the editable form. Check the selection, prepare a fresh Preview and approve independently. No prices changed.', 'writeleash' ), 'preset_references_changed' => __( 'Some saved product, SKU or category references are missing or unavailable. Exact saved inputs are retained. Review them before a fresh Preview, which checks current eligibility, permissions and limits. No prices changed.', 'writeleash' ), 'preset_updated' => __( 'Preset updated. Existing previews, jobs, History and Undo are unchanged.', 'writeleash' ), 'post_required' => __( 'This action requires an authenticated POST request.', 'writeleash' ), - 'invalid_nonce' => __( 'The security token is missing or invalid. Reload the page and try again.', 'writeleash' ), + 'invalid_nonce' => __( 'The security token is missing or invalid. Refresh the page and try again.', 'writeleash' ), 'woocommerce_unavailable' => __( 'WooCommerce must be active and initialized before bulk-price planning. Install and activate WooCommerce, then reopen this page. No job was created and no product was changed.', 'writeleash' ), 'permission_denied' => __( 'You do not have permission to plan these product edits.', 'writeleash' ), 'permission_revoked' => __( 'The approved actor can no longer edit one or more products; nothing was executed.', 'writeleash' ), @@ -1361,7 +1363,7 @@ public static function reason_message( string $reason, ?int $selected = null ): 'job_terminal' => __( 'This job has already finished.', 'writeleash' ), 'job_not_resumable' => __( 'This job cannot be continued from its current state.', 'writeleash' ), 'job_not_approvable' => __( 'This job cannot be approved in its current state.', 'writeleash' ), - 'approved' => __( 'Approved. WriteLeash will apply the reviewed prices in the background. Reload this page to check progress.', 'writeleash' ), + 'approved' => __( 'Approved. WriteLeash will apply the reviewed prices in the background. Progress updates automatically.', 'writeleash' ), 'approved_scheduler_unavailable' => __( 'Approved, but background processing could not start. This job is paused; use Resume remaining products to continue it.', 'writeleash' ), 'resume_chunk' => __( 'Results updated after this step. Check changes, remaining products and anything needing attention below.', 'writeleash' ), 'undo_chunk' => __( 'Undo results updated. Check restorations, remaining products and anything needing attention below.', 'writeleash' ), @@ -1500,6 +1502,27 @@ public static function undo_summary( array $undo ): string { return implode( ' · ', $parts ); } + /** Presentation-only Apply bar values from saved counters; hidden on unknown/zero totals. */ + public static function progress_bar_values( array $counts ): array { + $total = (int) ( $counts['planned'] ?? 0 ); + $processed = $total - (int) ( $counts['pending'] ?? 0 ) - (int) ( $counts['applying'] ?? 0 ); + if ( $processed < 0 ) { $processed = 0; } + if ( $processed > $total ) { $processed = $total; } + return array( $total, $processed ); + } + + /** Presentation-only Undo bar values from saved counters; hidden on unknown/zero totals. */ + public static function undo_progress_bar_values( array $undo ): array { + $total = (int) ( $undo['eligible'] ?? 0 ); + if ( $total < 1 ) { + $total = (int) ( $undo['undone'] ?? 0 ) + (int) ( $undo['pending'] ?? 0 ) + (int) ( $undo['applying'] ?? 0 ) + (int) ( $undo['conflict'] ?? 0 ) + (int) ( $undo['failed'] ?? 0 ) + (int) ( $undo['needs_review'] ?? 0 ); + } + $processed = $total - (int) ( $undo['pending'] ?? 0 ) - (int) ( $undo['applying'] ?? 0 ); + if ( $processed < 0 ) { $processed = 0; } + if ( $processed > $total ) { $processed = $total; } + return array( $total, $processed ); + } + public static function selection_count_message( array $count ): string { $range = ( isset( $count['range'] ) && is_array( $count['range'] ) && ! empty( $count['range']['enabled'] ) ) ? $count['range'] : null; $excluded = (int) ( $count['excluded_by_range'] ?? 0 ); @@ -1518,7 +1541,8 @@ public static function selection_count_message( array $count ): string { } private static function support_details( string $text ): void { - echo '
' . esc_html__( 'Support details', 'writeleash' ) . '

' . esc_html( $text ) . '

'; + if ( '' === trim( str_replace( '·', '', $text ) ) ) { return; } + echo '
' . esc_html__( 'Details', 'writeleash' ) . '

' . esc_html( $text ) . '

'; } /** @@ -1748,7 +1772,7 @@ public static function undo_availability( array $job, array $history ): string { if ( (int) $job['applied'] < 1 ) { return __( 'Undo unavailable: no products were changed by this job.', 'writeleash' ); } if ( Undo_State::NEEDS_REVIEW === $status ) { return __( 'Undo needs checking: a restoration outcome is uncertain. Continuing will not retry uncertain or conflicted products.', 'writeleash' ); } if ( Undo_State::PAUSED === $status ) { return __( 'Undo paused. Continue Undo for remaining products.', 'writeleash' ); } - if ( in_array( $status, array( Undo_State::PENDING, Undo_State::RUNNING ), true ) ) { return __( 'Undo in progress. Reload to check results or continue remaining products.', 'writeleash' ); } + if ( in_array( $status, array( Undo_State::PENDING, Undo_State::RUNNING ), true ) ) { return __( 'Undo in progress. Progress updates automatically.', 'writeleash' ); } if ( null !== $status ) { return __( 'Undo outcome unavailable; needs checking.', 'writeleash' ); } return $history['undo_eligible'] ? __( 'Undo available for prices changed by this job.', 'writeleash' ) : __( 'Undo unavailable: saved restoration evidence is unavailable.', 'writeleash' ); } @@ -1962,15 +1986,14 @@ public static function handle_export(): void { private static function render_home_view( array $form = array() ): void { echo '

'; - echo esc_html( __( 'Preview bulk regular or sale prices before they happen. Run them safely. If the price or a checked setting changed after review, WriteLeash leaves the newer value alone. Undo changes that are still safe to restore.', 'writeleash' ) ); - echo '

'; - echo '

'; - echo esc_html( __( 'Change the stored price of published simple products and variations of published variable products in the base store currency. Variations can be selected as a whole product or individually. Sale dates, stock, orders and subscriptions are not changed.', 'writeleash' ) ); - echo '

'; - echo '

'; - - echo esc_html( sprintf( /* translators: %d: maximum selected products per new job. */ __( 'Up to %d selected products per new job in the tested configuration. Planned prices are not guaranteed shopper prices: taxes, currency settings, dynamic pricing and later edits still apply. You can close this page at any time; saved progress appears again when you return.', 'writeleash' ), Free_Support_Contract::MAX_JOB_PRODUCTS ) ); + echo esc_html( __( 'Preview bulk regular or sale prices safely. No prices change until you approve.', 'writeleash' ) ); echo '

'; + echo '
' . esc_html__( 'How it works', 'writeleash' ) . '
    '; + echo '
  1. ' . esc_html( sprintf( /* translators: %d: maximum selected products per new job. */ __( 'Select products or one category, up to %d per new job. Variations can be selected as a whole product or individually.', 'writeleash' ), Free_Support_Contract::MAX_JOB_PRODUCTS ) ) . '
  2. '; + echo '
  3. ' . esc_html__( 'Preview without changing prices. If a price or checked setting changed after review, WriteLeash leaves the newer value alone.', 'writeleash' ) . '
  4. '; + echo '
  5. ' . esc_html__( 'Approve to apply exactly the prices shown. Planned prices are not guaranteed shopper prices.', 'writeleash' ) . '
  6. '; + echo '
  7. ' . esc_html__( 'Track results in history. Undo restores eligible prices; later changes are left alone.', 'writeleash' ) . '
  8. '; + echo '
'; if ( ! self::can_mutate() ) { echo '

' . esc_html( __( 'Your role can view this page but cannot preview or apply bulk price changes.', 'writeleash' ) ) . '

'; } @@ -2096,8 +2119,14 @@ private static function render_selector_form( array $values = array(), ?array $r foreach ( $options as $id => $item ) { echo ''; } echo '

' . esc_html__( 'Check the selected products below. Preview checks their eligibility before any prices change.', 'writeleash' ) . '

'; echo '

' . esc_html__( 'Selected products', 'writeleash' ) . '

'; echo '
' . esc_html__( 'Search categories without live suggestions', 'writeleash' ) . ''; @@ -2192,7 +2221,7 @@ private static function render_recent_jobs(): void { // Actor-scoped in SQL: the page contains only caller-visible jobs. $page = Undo_Repository::history_jobs( 0, 5, $user_id ); } catch ( \Throwable $error ) { - echo '

' . esc_html( __( 'History is unavailable right now. Reload the page to try again.', 'writeleash' ) ) . '

'; + echo '

' . esc_html( __( 'History is unavailable right now. Refresh the page to try again.', 'writeleash' ) ) . '

'; return; } self::render_history_table( $page['jobs'] ); @@ -2218,6 +2247,20 @@ private static function load_job_for_view( string $public_id ): ?array { return $job; } + /** + * Single approve-form renderer for the Preview view. The top summary + * panel and the bottom approval block call this same renderer, so both + * forms carry the same action, job field and plan nonce. Presentation + * only; no contract change. + */ + private static function render_approve_form( array $job ): void { + echo '
'; + echo ''; + echo ''; + wp_nonce_field( self::ACTION_APPROVE . '_' . $job['plan_id'] ); + echo '

'; + } + private static function render_preview_view( string $public_id, int $offset ): void { if ( ! self::jobs_installed() ) { echo '

' . esc_html( __( 'No preview is visible to your account for that identifier.', 'writeleash' ) ) . '

'; @@ -2274,7 +2317,17 @@ private static function render_preview_view( string $public_id, int $offset ): v echo '

' . esc_html( sprintf( /* translators: 1: selected products, 2: translated outcome counts. */ _n( 'Selected %1$d product: %2$s.', 'Selected %1$d products: %2$s.', $summary['selected'], 'writeleash' ), $summary['selected'], implode( ' · ', array( self::count_copy( 'changing', $summary['changing'] ), self::count_copy( 'unchanged', $summary['unchanged'] ), self::count_copy( 'skipped', $summary['unsupported'] ) ) ) ) ) . '

'; $extra_counts = self::preview_extra_counts( $data['items'] ); - echo '

' . esc_html( implode( ' · ', array( self::count_copy( 'large_increase', $extra_counts['large_increase'] ), self::count_copy( 'large_decrease', $extra_counts['large_decrease'] ), self::count_copy( 'zero_target', $extra_counts['zero_target'] ), self::count_copy( 'warning', $summary['warning_items'] ) ) ) . '.' ) . '

'; + echo '

' . esc_html( implode( ' · ', array( self::count_copy( 'large_increase', $extra_counts['large_increase'] ), self::count_copy( 'large_decrease', $extra_counts['large_decrease'] ), self::count_copy( 'zero_target', $extra_counts['zero_target'] ), self::count_copy( 'warning', $summary['warning_items'] ) ) ) . '.' ) . '

'; + // Top approve summary: same task description and compact counts as + // above, plus the shopper-price caveat and the same approve form as + // the bottom block, so summary and approval share the first viewport + // without JavaScript. The bottom block stays unchanged. + $approvable = ! $blocked && Job_State::PLANNED === $job['status'] && self::can_mutate(); + if ( $approvable ) { + echo '

' . esc_html__( 'Planned prices are not guaranteed shopper prices.', 'writeleash' ) . '

'; + self::render_approve_form( $job ); + } + echo ''; if ( $blocked ) { echo '