From 4df31051950bcf8e8738fd2df52d17344f7c37fe Mon Sep 17 00:00:00 2001 From: MrDarkRoot Date: Wed, 14 Oct 2026 21:25:09 +0700 Subject: [PATCH] ui(free): explain partial outcomes in merchant language (#281) --- docs/review/170/README.md | 10 ++ docs/review/170/proof.json | 2 +- wordpress/tests/admin/integration.php | 2 +- wordpress/tests/admin/outcome-copy-unit.php | 67 ++++++++++++ .../tests/admin/presentation-browser.cjs | 2 +- .../tests/admin/presentation-integration.php | 7 +- wordpress/tests/admin/progress-unit.php | 2 +- wordpress/tests/admin/run.sh | 1 + wordpress/tests/admin/selection-browser.cjs | 2 +- .../includes/free/class-free-admin.php | 102 ++++++++++++++++-- 10 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 wordpress/tests/admin/outcome-copy-unit.php diff --git a/docs/review/170/README.md b/docs/review/170/README.md index 866767dc..2c8b9b7e 100644 --- a/docs/review/170/README.md +++ b/docs/review/170/README.md @@ -205,3 +205,13 @@ 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. + +### #281 merchant outcome copy source refresh + +The Admin fingerprint now binds presentation-only merchant outcome lines in +`class-free-admin.php` (item headline, persisted-count summary, one existing +next step). No planner math, storage, nonce, route, or handler change. +Historical PNGs, result JSONs, captions and the conflict observer sentence +remain byte-for-byte unchanged; this refresh does not claim new screenshots. +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 9946d7d3..d5e5898a 100644 --- a/docs/review/170/proof.json +++ b/docs/review/170/proof.json @@ -2,7 +2,7 @@ "schema": 1, "base_main": "bd66a79", "source_hashes": { - "class-free-admin.php": "85ec78ba49b79727d113ffc6f7ee720163691e16ca7b12f605ab4d498cb78b8e", + "class-free-admin.php": "806698fb7788b1e89904a52d95912713b05d2561b4e7ff31075bf59200de396a", "free-selection.js": "19f60a7914b3b845e5adb9585f4b347e93e61d1ccd44d1e04b7601501e32e28e", "free-selection.css": "77673d2620b0fea759ef4ffa168f175a8ff1ead605cf6cac96a76ac4f155b199" }, diff --git a/wordpress/tests/admin/integration.php b/wordpress/tests/admin/integration.php index 463081f3..8faf7040 100644 --- a/wordpress/tests/admin/integration.php +++ b/wordpress/tests/admin/integration.php @@ -738,7 +738,7 @@ function wl122_current_rows( string $html ): array { finally { remove_filter( 'query', $trace_queries ); remove_filter( 'woocommerce_product_get_regular_price', $shopper_price ); } $current_rows = wl122_current_rows( $current_html ); eq( array_slice( $current_rows[$current_id], 1, 3 ), array( '$18.00 USD', '$21.00 USD', '$14.40 USD' ), 'same conflict row preserves saved and current decimal strings' ); -ok( str_contains( $current_rows[$current_id][4], 'Not changed' ) && str_contains( $current_rows[$current_id][4], 'left the newer value unchanged' ), 'merchant conflict explanation in the affected row' ); + ok( str_contains( $current_rows[$current_id][4], 'Newer price kept' ) && str_contains( $current_rows[$current_id][4], 'left the newer value unchanged' ), 'merchant conflict explanation in the affected row' ); eq( $current_rows[$current_other][2], '$19.20 USD', 'ordinary applied row also uses fresh Current' ); foreach ( $render_queries as $query ) { ok( ! preg_match( '/^\s*(?:INSERT|UPDATE|DELETE|REPLACE|CREATE|ALTER|DROP|TRUNCATE|GRANT|REVOKE|START\s+TRANSACTION|BEGIN|LOCK)\b|\bFOR\s+UPDATE\b/i', $query ), 'Current render is SELECT-only, with no mutation/worker lock' ); diff --git a/wordpress/tests/admin/outcome-copy-unit.php b/wordpress/tests/admin/outcome-copy-unit.php new file mode 100644 index 00000000..54f1e940 --- /dev/null +++ b/wordpress/tests/admin/outcome-copy-unit.php @@ -0,0 +1,67 @@ +getMethod( 'merchant_item_line' ); +$line->setAccessible( true ); +$summary = $outcome->getMethod( 'merchant_outcome_summary' ); +$summary->setAccessible( true ); +$next = $outcome->getMethod( 'merchant_next_step' ); +$next->setAccessible( true ); +$cell = $outcome->getMethod( 'render_item_outcome' ); +$cell->setAccessible( true ); + +$expect = array( + 'APPLIED' => 'Changed', + 'UNCHANGED' => 'Not changed', + 'UNSUPPORTED' => 'Skipped', + 'CONFLICT' => 'Newer price kept', + 'FAILED' => 'Not changed; needs attention', + 'NEEDS_REVIEW' => 'Needs review; outcome uncertain', + 'UNDONE' => 'Restored', + 'UNDO_CONFLICT' => 'Not restored; newer price kept', + 'UNDO_FAILED' => 'Not restored; needs attention', + 'UNDO_NEEDS_REVIEW' => 'Needs review; restoration uncertain', +); +foreach ( $expect as $state => $copy ) { + check204( $line->invoke( null, $state, 'COMPLETED_WITH_ISSUES' ), $copy, 'merchant line ' . $state ); + check204( preg_match( '/rolled back|full rollback|accessibility certified/i', $copy ), 0, 'merchant line stays bounded: ' . $state ); +} +check204( WriteLeash\Free_Admin::item_label( 'FAILED', 'COMPLETED_WITH_ISSUES' ), 'Needs attention; not changed', 'item_label contract unchanged' ); +check204( WriteLeash\Free_Admin::item_label( 'UNSUPPORTED', 'PLANNED' ), 'Skipped at preview', 'item_label skip contract unchanged' ); +check204( WriteLeash\Free_Admin::item_label( 'CONFLICT', 'COMPLETED_WITH_ISSUES' ), 'Not changed', 'item_label conflict contract unchanged' ); +check204( str_contains( WriteLeash\Free_Admin::result_summary( array( 'planned' => 4, 'pending' => 0, 'applying' => 0, 'applied' => 1, 'unchanged' => 1, 'conflict' => 2, 'failed' => 0, 'needs_review' => 0, 'unsupported' => 1 ), 'COMPLETED_WITH_ISSUES', true ), '2 conflicts' ), true, 'result_summary contract unchanged' ); + +$counts = array( 'planned' => 6, 'pending' => 1, 'applying' => 1, 'applied' => 1, 'unchanged' => 1, 'conflict' => 1, 'failed' => 1, 'needs_review' => 1, 'unsupported' => 1 ); +check204( $summary->invoke( null, $counts, 'RUNNING' ), 'Outcomes: 1 changed · 1 not changed · 1 skipped · 1 newer price kept · 1 not changed; needs attention · 1 needs review; outcome uncertain · 1 in progress; outcome not yet confirmed · 1 remaining.', 'partial summary uses persisted counts' ); +check204( $summary->invoke( null, $counts, 'COMPLETED_WITH_ISSUES' ), 'Outcomes: 1 changed · 1 not changed · 1 skipped · 1 newer price kept · 1 not changed; needs attention · 1 needs review; outcome uncertain · 1 in progress; outcome not yet confirmed · 1 not processed.', 'terminal pending is not processed' ); +check204( $summary->invoke( null, array( 'planned' => 2, 'pending' => 2, 'applied' => 0, 'unchanged' => 0, 'unsupported' => 0, 'conflict' => 0, 'failed' => 0, 'needs_review' => 0 ), 'PLANNED' ), '', 'planned pending is not invented as an outcome' ); +check204( str_contains( $summary->invoke( null, $counts, 'RUNNING' ), '%' ), false, 'merchant summary does not infer a percent' ); + +check204( $next->invoke( null, true, true, true ), 'Next: use Resume remaining products. Completed changes are kept.', 'one next step prefers the existing Resume control' ); +check204( $next->invoke( null, false, true, true ), 'Next: use Re-preview conflicted products to build a fresh preview. Prices do not change until you approve that preview.', 'conflict next step is a fresh preview' ); +check204( $next->invoke( null, false, false, true ), 'Next: Undo restores eligible prices only. It does not reverse orders, sales, emails, webhooks, or other plugins.', 'Undo next step is not a full rollback' ); +check204( $next->invoke( null, false, false, false ), 'Next: review the product.', 'default next step is inspect the product' ); + +ob_start(); +$cell->invoke( null, 'FAILED', null, 'COMPLETED_WITH_ISSUES', array( 'context' => '' ) ); +$failed_html = (string) ob_get_clean(); +check204( str_contains( $failed_html, 'Not changed; needs attention' ), true, 'failed cell headline' ); +check204( str_contains( $failed_html, 'Review the product. This outcome is not confirmed as unchanged.' ), true, 'failed cell one next step' ); +check204( str_contains( $failed_html, 'FAILED ·' ), true, 'typed failed state stays in Details' ); +check204( str_contains( $failed_html, 'rolled back' ), false, 'failed cell does not claim a rollback' ); +check204( str_contains( $failed_html, 'name="action"' ), false, 'outcome cell adds no form' ); + +ob_start(); +$cell->invoke( null, 'CONFLICT', null, 'COMPLETED_WITH_ISSUES', array( 'context' => '' ) ); +$conflict_html = (string) ob_get_clean(); +check204( str_contains( $conflict_html, 'Newer price kept' ), true, 'conflict cell headline' ); +check204( str_contains( $conflict_html, 'Create a new preview' ), true, 'conflict cell keeps the existing preview link' ); +check204( substr_count( $conflict_html, ' 'FORBIDDEN', 'reason' => 'prog // text, dropping the outcome emphasis and the conflict next-action links that // the server-rendered cells carry. The payload must ship the same markup. $html_rows = Admin::progress_snapshot( $post, 'POST' )['rows'][0]; - check204( str_contains( $html_rows['apply_html'], 'Not changed' ), true, 'polled Apply cell keeps the outcome emphasis' ); + check204( str_contains( $html_rows['apply_html'], 'Newer price kept' ), true, 'polled Apply cell keeps the merchant outcome emphasis' ); check204( str_contains( $html_rows['apply_html'], $long_apply ), true, 'polled Apply cell keeps the long guidance' ); check204( str_contains( $html_rows['apply_html'], 'Create a new preview' ), true, 'polled Apply cell keeps the conflict next action' ); check204( str_contains( $html_rows['apply_html'], '/dev/null node "$here/progress-client.cjs" php "$here/progress-unit.php" +php "$here/outcome-copy-unit.php" php "$here/recovery-unit.php" php "$here/repeat-unit.php" php "$here/presets-unit.php" diff --git a/wordpress/tests/admin/selection-browser.cjs b/wordpress/tests/admin/selection-browser.cjs index 53db1d29..9c272354 100644 --- a/wordpress/tests/admin/selection-browser.cjs +++ b/wordpress/tests/admin/selection-browser.cjs @@ -240,7 +240,7 @@ async function search(page, term) { await page.getByRole('link', { name: 'Continue review', exact: true }).click(); await action(page, 'Approve and apply'); await action(page, 'Resume remaining products'); - ok((await page.locator('#wpbody-content').innerText()).includes('Not changed'), 'existing execution checks detect stale product'); + ok((await page.locator('#wpbody-content').innerText()).includes('Newer price kept'), 'existing execution checks detect stale product'); ok(Number(observe().prices[fixture.products[0]].stored) === 120, 'independent observer proves no blind overwrite'); await page.goto(previewURL); ok(await page.getByRole('button', { name: 'Approve and apply', exact: true }).count() === 0, 'approved preview routes to actual results, cannot reapprove'); diff --git a/wordpress/writeleash/includes/free/class-free-admin.php b/wordpress/writeleash/includes/free/class-free-admin.php index e8459728..28d1560d 100644 --- a/wordpress/writeleash/includes/free/class-free-admin.php +++ b/wordpress/writeleash/includes/free/class-free-admin.php @@ -1720,6 +1720,80 @@ private static function outcome_conflict_copy( ?string $state ): string { return ''; } + private static function merchant_item_line( ?string $state, string $job_state ): string { + $lines = array( + 'APPLIED' => __( 'Changed', 'writeleash' ), + 'UNCHANGED' => __( 'Not changed', 'writeleash' ), + 'UNSUPPORTED' => __( 'Skipped', 'writeleash' ), + 'CONFLICT' => __( 'Newer price kept', 'writeleash' ), + 'FAILED' => __( 'Not changed; needs attention', 'writeleash' ), + 'NEEDS_REVIEW' => __( 'Needs review; outcome uncertain', 'writeleash' ), + 'UNDONE' => __( 'Restored', 'writeleash' ), + 'UNDO_CONFLICT' => __( 'Not restored; newer price kept', 'writeleash' ), + 'UNDO_FAILED' => __( 'Not restored; needs attention', 'writeleash' ), + 'UNDO_NEEDS_REVIEW' => __( 'Needs review; restoration uncertain', 'writeleash' ), + ); + if ( null !== $state && isset( $lines[ $state ] ) ) { return $lines[ $state ]; } + return self::item_label( $state, $job_state ); + } + + private static function merchant_count_phrase( string $kind, int $count ): string { + switch ( $kind ) { + case 'changed': + /* translators: %d: number of products whose price was changed. */ + return sprintf( _n( '%d changed', '%d changed', $count, 'writeleash' ), $count ); + case 'unchanged': + /* translators: %d: number of products that were not changed. */ + return sprintf( _n( '%d not changed', '%d not changed', $count, 'writeleash' ), $count ); + case 'skipped': + /* translators: %d: number of products skipped. */ + return sprintf( _n( '%d skipped', '%d skipped', $count, 'writeleash' ), $count ); + case 'conflict': + /* translators: %d: number of products whose newer price was kept. */ + return sprintf( _n( '%d newer price kept', '%d newer prices kept', $count, 'writeleash' ), $count ); + case 'failed': + /* translators: %d: number of products not changed that need attention. */ + return sprintf( _n( '%d not changed; needs attention', '%d not changed; needs attention', $count, 'writeleash' ), $count ); + case 'review': + /* translators: %d: number of products whose outcome is uncertain. */ + return sprintf( _n( '%d needs review; outcome uncertain', '%d need review; outcome uncertain', $count, 'writeleash' ), $count ); + case 'remaining': + /* translators: %d: number of products still remaining. */ + return sprintf( _n( '%d remaining', '%d remaining', $count, 'writeleash' ), $count ); + case 'unprocessed': + /* translators: %d: number of products not processed. */ + return sprintf( _n( '%d not processed', '%d not processed', $count, 'writeleash' ), $count ); + case 'applying': + /* translators: %d: number of products still in progress. */ + return sprintf( _n( '%d in progress; outcome not yet confirmed', '%d in progress; outcome not yet confirmed', $count, 'writeleash' ), $count ); + } + return ''; + } + + private static function merchant_outcome_summary( array $counts, string $state ): string { + $parts = array(); + foreach ( array( 'applied' => 'changed', 'unchanged' => 'unchanged', 'unsupported' => 'skipped', 'conflict' => 'conflict', 'failed' => 'failed', 'needs_review' => 'review' ) as $key => $kind ) { + $count = (int) ( $counts[ $key ] ?? 0 ); + if ( $count > 0 ) { $parts[] = self::merchant_count_phrase( $kind, $count ); } + } + $applying = (int) ( $counts['applying'] ?? 0 ); + if ( $applying > 0 ) { $parts[] = self::merchant_count_phrase( 'applying', $applying ); } + $pending = (int) ( $counts['pending'] ?? 0 ); + if ( $pending > 0 && ! in_array( $state, array( 'PLANNED', 'BLOCKED', 'DRAFT', 'PLANNING' ), true ) ) { + $parts[] = self::merchant_count_phrase( Job_State::is_terminal( $state ) ? 'unprocessed' : 'remaining', $pending ); + } + if ( ! $parts ) { return ''; } + /* translators: %s: saved outcome counts in merchant language. */ + return sprintf( __( 'Outcomes: %s.', 'writeleash' ), implode( ' · ', $parts ) ); + } + + private static function merchant_next_step( bool $resume_available, bool $repreview_available, bool $undo_available ): string { + if ( $resume_available ) { return __( 'Next: use Resume remaining products. Completed changes are kept.', 'writeleash' ); } + if ( $repreview_available ) { return __( 'Next: use Re-preview conflicted products to build a fresh preview. Prices do not change until you approve that preview.', 'writeleash' ); } + if ( $undo_available ) { return __( 'Next: Undo restores eligible prices only. It does not reverse orders, sales, emails, webhooks, or other plugins.', 'writeleash' ); } + return __( 'Next: review the product.', 'writeleash' ); + } + /** Polled outcome cells reuse the server cell renderer byte-for-byte. */ private static function outcome_cell_html( array $item, array $frozen, $reason, string $job_state, array $observation, bool $apply_side ): string { if ( $apply_side ) { @@ -1736,7 +1810,7 @@ private static function outcome_cell_html( array $item, array $frozen, $reason, } private static function render_item_outcome( ?string $state, ?string $reason, string $job_state, array $observation ): void { - echo '' . esc_html( self::item_label( $state, $job_state ) ) . ''; + echo '' . esc_html( self::merchant_item_line( $state, $job_state ) ) . ''; if ( 'CONFLICT' === $state || 'UNDO_CONFLICT' === $state ) { $copy = self::outcome_conflict_copy( $state ); echo '

' . esc_html( $copy ) . '

'; @@ -1747,7 +1821,7 @@ private static function render_item_outcome( ?string $state, ?string $reason, st /* translators: 1: opening link to a new preview, 2: closing link. */ echo '

' . sprintf( esc_html__( 'Review the product, then %1$sCreate a new preview%2$s if you still want to change it.', 'writeleash' ), '', '' ) . '

'; } elseif ( in_array( $state, array( 'NEEDS_REVIEW', 'UNKNOWN', 'UNDO_NEEDS_REVIEW', 'FAILED', 'UNDO_FAILED' ), true ) ) { - echo '

' . esc_html__( 'Check the product and saved results before taking further action. This outcome is not confirmed as unchanged.', 'writeleash' ) . '

'; + echo '

' . esc_html__( 'Review the product. This outcome is not confirmed as unchanged.', 'writeleash' ) . '

'; // Known apply-time refusals get their exact merchant recovery copy; // unknown reasons never invent an explanation. if ( null !== $reason && in_array( $reason, array( 'LOOKUP_MISMATCH', 'UNSUPPORTED_PRODUCT_STATE' ), true ) ) { @@ -1803,7 +1877,11 @@ private static function render_history_table( array $entries ): void { if ( (int) $job['approver_id'] > 0 ) { echo '
' . esc_html( sprintf( /* translators: %s: approver display name. */ __( 'Approved by %s', 'writeleash' ), self::operator_name( (int) $job['approver_id'] ) ) ); } echo '
' . esc_html( self::site_time( $job['created_at'] ) ) . ''; self::support_details( $job['public_id'] ); echo ''; - echo '' . esc_html( self::job_label( $job['status'] ) ) . '
' . esc_html( self::result_summary( $entry['apply'], $job['status'], true ) ) . ''; + echo '' . esc_html( self::job_label( $job['status'] ) ) . '
'; + $merchant_history = self::merchant_outcome_summary( $entry['apply'], $job['status'] ); + echo esc_html( '' !== $merchant_history ? $merchant_history : self::result_summary( $entry['apply'], $job['status'], true ) ); + self::support_details( $job['status'] . ' · ' . (string) ( $job['status_reason'] ?? '' ) ); + echo ''; echo '' . esc_html( self::undo_availability( $job, $entry ) ); if ( $entry['undo_eligible'] ) { echo '
' . esc_html( sprintf( /* translators: %s: localized expiry date and time. */ __( 'Until %s', 'writeleash' ), self::site_time( $entry['undo_expires_at'] ) ) ); } @@ -2641,6 +2719,13 @@ private static function render_job_view( string $public_id, int $offset, string $counts = $observed['counts']; $effective = $observed['effective_status']; $complete_evidence = $counts['planned'] === $selected; + $resume_available = $complete_evidence && Job_State::can_manual_run( $job['status'] ) && self::can_mutate(); + $repreview_available = false; + try { + $recovery = self::recovery_source( $public_id ); + $repreview_available = ! empty( $recovery['rows'] ); + } catch ( \Throwable $error ) { /* No recovery action without verified authority and evidence. */ } + $undo_form_available = $complete_evidence && ! empty( $history['undo_eligible'] ) && self::can_mutate() && ( empty( $history['undo']['operation_status'] ) || Undo_State::can_manual_run( $history['undo']['operation_status'] ) ); echo '

' . esc_html( __( 'Back to bulk prices', 'writeleash' ) ) . ' '; echo '' . esc_html( __( 'Open history', 'writeleash' ) ) . '

'; if ( self::reviewable( $job ) ) { self::job_action_link( $job, true ); echo '

' . esc_html__( 'Create a new preview', 'writeleash' ) . '

'; } @@ -2650,6 +2735,11 @@ private static function render_job_view( string $public_id, int $offset, string echo '

' . esc_html( $complete_evidence ? self::job_label( $effective ) : __( 'Saved product outcomes unavailable; needs checking', 'writeleash' ) ) . '

'; if ( $complete_evidence ) { echo '

' . esc_html( self::result_summary( $counts, $effective, true ) ) . '

'; } else { echo '

' . esc_html( sprintf( /* translators: %d: planned products. */ _n( 'Planned %d product. Some saved product evidence is unavailable. Check this job before taking further action.', 'Planned %d products. Some saved product evidence is unavailable. Check this job before taking further action.', $selected, 'writeleash' ), $selected ) ) . '

'; } + if ( $complete_evidence ) { + $merchant_summary = self::merchant_outcome_summary( $counts, $effective ); + if ( '' !== $merchant_summary ) { echo '

' . esc_html( $merchant_summary ) . '

'; } + } + echo '

' . esc_html( $complete_evidence ? self::merchant_next_step( $resume_available, $repreview_available, $undo_form_available ) : __( 'Next: refresh saved progress before taking further action.', 'writeleash' ) ) . '

'; $apply_bar = $complete_evidence ? self::progress_bar_values( $counts ) : array( 0, 0 ); self::render_progress_bar( $apply_bar[0], $apply_bar[1], __( 'Apply progress', 'writeleash' ), 'data-progress-bar', 'data-progress-fill' ); if ( in_array( $effective, array( Job_State::PAUSED, Job_State::NEEDS_REVIEW ), true ) ) { echo '

' . esc_html( self::reason_message( $observed['effective_reason'] ) ) . '

'; } @@ -2671,7 +2761,6 @@ private static function render_job_view( string $public_id, int $offset, string // Single primary per view: Resume is the primary action while it is // available; Undo drops to secondary then and returns to primary only // when Resume is unavailable. Presentation only; same hooks/fields. - $resume_available = $complete_evidence && Job_State::can_manual_run( $job['status'] ) && self::can_mutate(); if ( $resume_available ) { if ( in_array( $effective, array( Job_State::READY, Job_State::QUEUED ), true ) && ! $observed['stalled'] ) { echo '

' . esc_html( __( 'Waiting for background processing. Use Resume remaining products to run the next step now.', 'writeleash' ) ) . '

'; } echo '
'; @@ -2682,10 +2771,7 @@ private static function render_job_view( string $public_id, int $offset, string echo esc_html( __( 'Runs only the remaining products; completed changes are kept. Products changed since Preview are left alone.', 'writeleash' ) ) . '

'; } echo ''; - try { - $recovery = self::recovery_source( $public_id ); - if ( $recovery['rows'] ) { echo '

' . esc_html__( 'Re-preview conflicted products', 'writeleash' ) . '

'; } - } catch ( \Throwable $error ) { /* No recovery action without verified authority and evidence. */ } + if ( $repreview_available ) { echo '

' . esc_html__( 'Re-preview conflicted products', 'writeleash' ) . '

'; } try { // Repeat is offered for any verified historical plan, independent // of Apply conflicts. Unsupported stored configurations stay