docs/test: resolve QA P1s + P2 guards (v0.8.0)
P1-A: CONTEXT.md — add glossary entries for _prab_* namespace, meta-reader pattern, ScholarlyArticle type, and honest-reviewedBy integrity constraint (Person-vs-Organization branching rule). P1-B: Master IA + app-map updated (workspace docs, not in this diff): INFORMATION-ARCHITECTURE.md v1.3.2 — §5 cross-plugin data flow added (PRAutoBlogger _prab_* meta → prcore JSON-LD Article emission); §4 version bumped to 0.8.0. peptide-repo-core.md — two new classes in §2 file tree; §3 data flow Mermaid extended with PRAB→MetaReader→Emitter chain; §5 cross-plugin dependency noted; §6 hooks table updated; last-verified bumped to v0.8.0. P1-C: JsonldArticleTest.php — add 5 new test methods: - test_standalone_fallback_emits_nothing_for_untriggered_post() - test_standalone_fallback_emits_valid_graph_for_triggered_post() - test_standalone_fallback_suppressed_when_yoast_active() - test_get_about_peptides_nonexistent_id_skipped() - test_get_about_peptides_wrong_post_type_skipped() Also add test_is_triggered_cache_returns_same_value() for P2-5 cache. P2-1: get_about_peptides() — skip entries where get_permalink() returns ''. P2-2: get_reviewed_by() — empty display_name falls through to Organization. P2-3: register_hooks() — explicit priority 10 on wpseo_schema_webpage_type. P2-4: emit_standalone_fallback() — guard wp_json_encode() false return; also guard empty permalink before building the graph. P2-5: is_triggered() — per-post avoids triple get_post_meta reads when all three hooks fire for the same post. P3s accepted (noted in QA verdict): P3-1 test-comment misleading; P3-2 docblock @see gap on emit_standalone_fallback; P3-3 DOI regex. Agent-Session: prcore-jsonld-remediation-20260623
This commit is contained in:
59
CONTEXT.md
59
CONTEXT.md
@@ -113,3 +113,62 @@ Gutenberg block. Not surfaced in JSON-LD (dosing YMYL constraint).
|
||||
`PR_Core_Migration_Runner` — runs numbered migrations (0001–N) sequentially on every
|
||||
`plugins_loaded`. Compares `pr_core_schema_version` wp_option against
|
||||
`PR_CORE_TARGET_SCHEMA_VERSION` constant. All migrations are idempotent and re-runnable.
|
||||
|
||||
## _prab_* meta namespace
|
||||
|
||||
Post-meta keys written exclusively by PRAutoBlogger (PRAB) and read by prcore's
|
||||
`PR_Core_Prab_Meta_Reader` at render time. The namespace is `_prab_` (PRAutoBlogger
|
||||
private). Ownership split: **PRAB writes, prcore reads**. No other plugin reads or
|
||||
writes `_prab_*` meta. Contract v1 keys (frozen; additive evolution only, gated by
|
||||
`_prab_schema_version`):
|
||||
|
||||
| Key | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `_prab_schema_version` | int (1) | Opt-in trigger; presence + value = 1 enables JSON-LD emission |
|
||||
| `_prab_citations` | JSON array | `[{url, doi?, title, quality_score?}]` — validated sources |
|
||||
| `_prab_about_peptides` | JSON array | Peptide post IDs → Drug `@id` references in `about[]` |
|
||||
| `_prab_review_mode` | string | `human` \| `editorial-system` |
|
||||
| `_prab_reviewed_at` | string | ISO 8601 datetime → `lastReviewed` |
|
||||
| `_prab_reviewed_by` | int | WP user ID of Review Queue approver (required when mode=`human`) |
|
||||
|
||||
All `_prab_*` values are treated as **untrusted at read time**: sanitized and
|
||||
validated by `PR_Core_Prab_Meta_Reader` before any schema.org output is produced.
|
||||
Malformed values degrade gracefully (skipped/null); no fatal path exists.
|
||||
Canonical contract: `convo/prcore/decisions/2026-06-11-jsonld-contract-v1.md`.
|
||||
|
||||
## Meta-reader pattern
|
||||
|
||||
`PR_Core_Prab_Meta_Reader` is the dedicated sanitizer class that isolates all
|
||||
`_prab_*` meta reading and validation from the emitter (`PR_Core_Jsonld_Article`).
|
||||
The pattern: **one sanitizer class per untrusted meta namespace**. The reader owns
|
||||
all `get_post_meta` calls for the namespace, validates every field, and returns
|
||||
typed clean values. The emitter (consumer) never calls `get_post_meta` directly.
|
||||
|
||||
This keeps the emitter testable without the meta contract details and ensures that
|
||||
adding a new `_prab_*` field never touches the emission logic. Future `_prab_v2_*`
|
||||
fields would use a v2 reader that the emitter selects by version.
|
||||
|
||||
## ScholarlyArticle
|
||||
|
||||
A `schema.org/ScholarlyArticle` node emitted inside a citation array (`citation[]`)
|
||||
on the `Article` graph piece when a validated DOI is present in the PRAB citation
|
||||
record. Emitted with `url` (the citation URL), `name` (the citation title), and
|
||||
`sameAs` (the canonical `https://doi.org/` URI). When no DOI is available,
|
||||
`CreativeWork` is emitted instead. Per contract v1, `quality_score` is never
|
||||
emitted (no schema.org mapping).
|
||||
|
||||
## Honest reviewedBy (integrity constraint)
|
||||
|
||||
The `reviewedBy` field on the `MedicalWebPage` node emits a `schema.org/Person`
|
||||
node **only** when all three conditions hold simultaneously:
|
||||
1. `_prab_review_mode` = `human`
|
||||
2. `_prab_reviewed_by` resolves to a WP user ID > 0
|
||||
3. `get_userdata()` returns a valid `WP_User` instance with a non-empty
|
||||
`display_name`
|
||||
|
||||
Any other combination (missing mode, zero user ID, unresolvable user, or
|
||||
editorial-system mode) produces a `schema.org/Organization` node (Peptide Repo
|
||||
editorial). This is an **integrity constraint**, not a UI choice: emitting a
|
||||
fabricated Person would constitute misleading structured data. The constraint is
|
||||
enforced in `PR_Core_Prab_Meta_Reader::get_reviewed_by()` and covered by dedicated
|
||||
unit tests.
|
||||
|
||||
@@ -62,7 +62,7 @@ class PR_Core_Jsonld_Article {
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_filter( 'wpseo_schema_webpage_type', array( $this, 'retype_article_page' ) );
|
||||
add_filter( 'wpseo_schema_webpage_type', array( $this, 'retype_article_page' ), 10 );
|
||||
add_filter( 'wpseo_schema_graph', array( $this, 'enrich_article_graph' ), 13, 2 );
|
||||
add_action( 'wp_head', array( $this, 'emit_standalone_fallback' ), 99 );
|
||||
}
|
||||
@@ -151,7 +151,7 @@ class PR_Core_Jsonld_Article {
|
||||
*
|
||||
* Suppressed when Yoast is loaded. Emits Article + MedicalWebPage nodes
|
||||
* with Yoast-parity @id conventions so consumers see one shape regardless
|
||||
* of whether Yoast is present.
|
||||
* of whether Yoast is present. Skips posts whose permalink cannot be resolved.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
@@ -167,7 +167,11 @@ class PR_Core_Jsonld_Article {
|
||||
return;
|
||||
}
|
||||
|
||||
$permalink = (string) get_permalink( $post_id );
|
||||
$permalink = (string) get_permalink( $post_id );
|
||||
if ( '' === $permalink ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post = get_post( $post_id );
|
||||
$review_mode = $this->reader->get_review_mode( $post_id );
|
||||
$reviewed_at = $this->reader->get_reviewed_at( $post_id );
|
||||
@@ -198,16 +202,19 @@ class PR_Core_Jsonld_Article {
|
||||
$article['about'] = $about_stubs;
|
||||
}
|
||||
|
||||
printf(
|
||||
'<script type="application/ld+json">%s</script>' . "\n",
|
||||
wp_json_encode(
|
||||
array(
|
||||
'@context' => 'https://schema.org',
|
||||
'@graph' => array( $webpage, $article ),
|
||||
),
|
||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
|
||||
)
|
||||
$json = wp_json_encode(
|
||||
array(
|
||||
'@context' => 'https://schema.org',
|
||||
'@graph' => array( $webpage, $article ),
|
||||
),
|
||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
|
||||
);
|
||||
if ( false !== $json ) {
|
||||
printf(
|
||||
'<script type="application/ld+json">%s</script>' . "\n",
|
||||
$json
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private builders ─────────────────────────────────────────────────
|
||||
|
||||
@@ -27,85 +27,70 @@ declare(strict_types=1);
|
||||
*/
|
||||
class PR_Core_Prab_Meta_Reader {
|
||||
|
||||
/**
|
||||
* Schema version meta key; presence opts the post in, value must equal 1.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
/** @var string Schema version meta key; value must equal 1 to trigger. */
|
||||
public const META_SCHEMA_VERSION = '_prab_schema_version';
|
||||
|
||||
/**
|
||||
* Citations meta key; JSON array of {url, title, doi?, quality_score?} objects.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
/** @var string Citations meta key; JSON array of {url, title, doi?, quality_score?}. */
|
||||
public const META_CITATIONS = '_prab_citations';
|
||||
|
||||
/**
|
||||
* About-peptides meta key; JSON array of peptide post IDs (ints) for schema `about`.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
/** @var string About-peptides meta key; JSON array of peptide post IDs for schema `about`. */
|
||||
public const META_ABOUT_PEPTIDES = '_prab_about_peptides';
|
||||
|
||||
/**
|
||||
* Review mode meta key; value is 'human' or 'editorial-system'.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
/** @var string Review mode meta key; value is 'human' or 'editorial-system'. */
|
||||
public const META_REVIEW_MODE = '_prab_review_mode';
|
||||
|
||||
/**
|
||||
* Reviewed-at meta key; ISO 8601 datetime of last review.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
/** @var string Reviewed-at meta key; ISO 8601 datetime of last review. */
|
||||
public const META_REVIEWED_AT = '_prab_reviewed_at';
|
||||
|
||||
/**
|
||||
* Reviewed-by meta key; stores the WP user ID of the Review Queue approver.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
/** @var string Reviewed-by meta key; WP user ID of the Review Queue approver. */
|
||||
public const META_REVIEWED_BY = '_prab_reviewed_by';
|
||||
|
||||
/**
|
||||
* The only schema version this reader currently supports.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
/** @var int The only schema version this reader currently supports. */
|
||||
private const SUPPORTED_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Per-request cache for is_triggered() results, keyed by post ID.
|
||||
*
|
||||
* Avoids triple get_post_meta reads when all three hooks fire for the same post.
|
||||
*
|
||||
* @var array<int, bool>
|
||||
*/
|
||||
private array $triggered_cache = array();
|
||||
|
||||
/**
|
||||
* Return true when this post carries a supported _prab_schema_version meta.
|
||||
*
|
||||
* Logs a notice on an unsupported-but-present version value.
|
||||
* Result is cached per post ID to avoid repeated get_post_meta reads within
|
||||
* one request (all three Yoast hooks call this for the same post).
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @return bool
|
||||
*/
|
||||
public function is_triggered( int $post_id ): bool {
|
||||
if ( isset( $this->triggered_cache[ $post_id ] ) ) {
|
||||
return $this->triggered_cache[ $post_id ];
|
||||
}
|
||||
$raw = get_post_meta( $post_id, self::META_SCHEMA_VERSION, true );
|
||||
if ( '' === $raw || false === $raw ) {
|
||||
return false;
|
||||
return $this->triggered_cache[ $post_id ] = false;
|
||||
}
|
||||
$version = (int) $raw;
|
||||
if ( self::SUPPORTED_VERSION === $version ) {
|
||||
return true;
|
||||
return $this->triggered_cache[ $post_id ] = true;
|
||||
}
|
||||
error_log( sprintf( '[PR Core] Unsupported _prab_schema_version=%d on post %d. Supports v%d only. Emission suppressed.', $version, $post_id, self::SUPPORTED_VERSION ) );
|
||||
return false;
|
||||
return $this->triggered_cache[ $post_id ] = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and validate _prab_citations meta.
|
||||
*
|
||||
* Each entry must have a valid http/https `url` and a non-empty `title`.
|
||||
* Invalid entries are skipped individually. `quality_score` is never emitted
|
||||
* (no schema.org mapping — contract v1).
|
||||
* Invalid entries are skipped. `quality_score` is never emitted (no schema.org
|
||||
* mapping — contract v1).
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @return array<int, array{url: string, title: string, doi: string|null}> Validated citations.
|
||||
* @return array<int, array{url: string, title: string, doi: string|null}> Validated.
|
||||
*/
|
||||
public function get_citations( int $post_id ): array {
|
||||
$decoded = $this->decode_json_meta( $post_id, self::META_CITATIONS );
|
||||
@@ -135,7 +120,8 @@ class PR_Core_Prab_Meta_Reader {
|
||||
* Read and validate _prab_about_peptides meta.
|
||||
*
|
||||
* Returns Drug stub arrays for schema `about`. Each peptide post ID must
|
||||
* resolve to a published `peptide` post; unresolvable IDs are skipped.
|
||||
* resolve to a published `peptide` post; unresolvable IDs and posts whose
|
||||
* permalink is empty are skipped.
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @return array<int, array<string, string>> Drug stubs.
|
||||
@@ -157,7 +143,10 @@ class PR_Core_Prab_Meta_Reader {
|
||||
|| PR_Core_Peptide_CPT::POST_TYPE !== $post->post_type ) {
|
||||
continue;
|
||||
}
|
||||
$link = (string) get_permalink( $pid );
|
||||
$link = (string) get_permalink( $pid );
|
||||
if ( '' === $link ) {
|
||||
continue;
|
||||
}
|
||||
$stubs[] = array(
|
||||
'@type' => 'Drug',
|
||||
'@id' => $link . '#drug',
|
||||
@@ -207,11 +196,11 @@ class PR_Core_Prab_Meta_Reader {
|
||||
* Resolve the honest reviewedBy value for a PRAB article.
|
||||
*
|
||||
* Returns a Person node only when mode='human' AND _prab_reviewed_by resolves
|
||||
* to an existing WP user. All other cases return the Organization node.
|
||||
* Never emits a fabricated Person — that is the core contract guarantee.
|
||||
* to an existing WP user with a non-empty display_name. All other cases return
|
||||
* the Organization node. Never emits a fabricated Person — core contract.
|
||||
*
|
||||
* Person @id: {home_url}/#/schema/person/{user_id} (Yoast convention).
|
||||
* Org @id: {home_url}/#organization (references Yoast's existing publisher node).
|
||||
* Org @id: {home_url}/#organization (references Yoast's publisher node).
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @param string $review_mode Result of get_review_mode() for this post.
|
||||
@@ -222,13 +211,15 @@ class PR_Core_Prab_Meta_Reader {
|
||||
$uid = absint( get_post_meta( $post_id, self::META_REVIEWED_BY, true ) );
|
||||
$user = $uid > 0 ? get_userdata( $uid ) : false;
|
||||
if ( $user instanceof WP_User ) {
|
||||
return array(
|
||||
'@type' => 'Person',
|
||||
'@id' => home_url( '/#/schema/person/' . $uid ),
|
||||
'name' => sanitize_text_field( $user->display_name ),
|
||||
);
|
||||
$display = sanitize_text_field( $user->display_name );
|
||||
if ( '' !== $display ) {
|
||||
return array(
|
||||
'@type' => 'Person',
|
||||
'@id' => home_url( '/#/schema/person/' . $uid ),
|
||||
'name' => $display,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Log missing/unresolvable user and fall through to Org.
|
||||
error_log( sprintf( '[PR Core] _prab_review_mode=human but reviewed_by user (ID=%d) unresolvable on post %d. Emitting Organization.', $uid, $post_id ) );
|
||||
}
|
||||
return array(
|
||||
|
||||
@@ -4,15 +4,20 @@
|
||||
*
|
||||
* Coverage:
|
||||
* - is_triggered: version=1 triggers, absent/wrong version does not.
|
||||
* - is_triggered cache: second call returns cached value (no extra meta read).
|
||||
* - get_review_mode: 'human', 'editorial-system', unknown → 'editorial-system'.
|
||||
* - get_reviewed_by: human + valid user → Person; human + bad user → Org; editorial → Org.
|
||||
* - get_reviewed_by: human + empty display_name → Org (P2-2 guard).
|
||||
* - get_citations: valid, missing url, missing title, bad url, doi normalisation.
|
||||
* - get_about_peptides: valid peptide ID, non-existent ID, non-peptide type.
|
||||
* - get_about_peptides: valid peptide ID; non-existent ID skipped; non-peptide type skipped.
|
||||
* - get_reviewed_at: valid ISO, empty, unparseable → null.
|
||||
* - retype_article_page: triggered post → MedicalWebPage; untriggered → passthrough.
|
||||
* - enrich_article_graph: Article gains citation/about; WebPage gains lastReviewed/reviewedBy.
|
||||
* - No duplicate Article/WebPage nodes emitted.
|
||||
* - editorial-system mode never emits Person.
|
||||
* - emit_standalone_fallback: Yoast-active suppresses output.
|
||||
* - emit_standalone_fallback: untriggered post emits nothing.
|
||||
* - emit_standalone_fallback: triggered post emits valid @graph with Article + MedicalWebPage.
|
||||
*
|
||||
* @package PeptideRepoCore\Tests
|
||||
*/
|
||||
@@ -77,6 +82,15 @@ class JsonldArticleTest extends TestCase {
|
||||
$this->assertFalse( $this->reader()->is_triggered( 1 ) );
|
||||
}
|
||||
|
||||
public function test_is_triggered_cache_returns_same_value(): void {
|
||||
$this->set_meta( 5, array( '_prab_schema_version' => '1' ) );
|
||||
$r = $this->reader();
|
||||
$first = $r->is_triggered( 5 );
|
||||
$second = $r->is_triggered( 5 );
|
||||
$this->assertSame( $first, $second );
|
||||
$this->assertTrue( $first );
|
||||
}
|
||||
|
||||
// ── get_review_mode ───────────────────────────────────────────────────
|
||||
|
||||
public function test_review_mode_human(): void {
|
||||
@@ -123,6 +137,13 @@ class JsonldArticleTest extends TestCase {
|
||||
$this->assertSame( 'Organization', $node['@type'] );
|
||||
}
|
||||
|
||||
public function test_reviewed_by_human_empty_display_name_returns_org(): void {
|
||||
$this->set_meta( 1, array( '_prab_reviewed_by' => '77' ) );
|
||||
$GLOBALS['pr_test_userdata'][77] = new WP_User( array( 'display_name' => '', 'ID' => 77 ) );
|
||||
$node = $this->reader()->get_reviewed_by( 1, 'human' );
|
||||
$this->assertSame( 'Organization', $node['@type'] );
|
||||
}
|
||||
|
||||
public function test_reviewed_by_editorial_system_returns_org(): void {
|
||||
$node = $this->reader()->get_reviewed_by( 1, 'editorial-system' );
|
||||
$this->assertSame( 'Organization', $node['@type'] );
|
||||
@@ -206,6 +227,34 @@ class JsonldArticleTest extends TestCase {
|
||||
$this->assertCount( 0, $this->reader()->get_citations( 1 ) );
|
||||
}
|
||||
|
||||
// ── get_about_peptides edge cases ─────────────────────────────────────
|
||||
|
||||
public function test_get_about_peptides_nonexistent_id_skipped(): void {
|
||||
// post ID 999 does not exist in $GLOBALS['pr_test_posts'] → get_post returns null.
|
||||
$this->set_meta( 1, array(
|
||||
'_prab_schema_version' => '1',
|
||||
'_prab_about_peptides' => wp_json_encode( array( 999 ) ),
|
||||
) );
|
||||
$stubs = $this->reader()->get_about_peptides( 1 );
|
||||
$this->assertCount( 0, $stubs );
|
||||
}
|
||||
|
||||
public function test_get_about_peptides_wrong_post_type_skipped(): void {
|
||||
// A published post of type 'post' (not 'peptide') should be excluded.
|
||||
$GLOBALS['pr_test_posts'][50] = new WP_Post( array(
|
||||
'ID' => 50,
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'publish',
|
||||
'post_title' => 'Not a peptide',
|
||||
) );
|
||||
$this->set_meta( 1, array(
|
||||
'_prab_schema_version' => '1',
|
||||
'_prab_about_peptides' => wp_json_encode( array( 50 ) ),
|
||||
) );
|
||||
$stubs = $this->reader()->get_about_peptides( 1 );
|
||||
$this->assertCount( 0, $stubs );
|
||||
}
|
||||
|
||||
// ── get_reviewed_at ───────────────────────────────────────────────────
|
||||
|
||||
public function test_reviewed_at_valid_iso(): void {
|
||||
@@ -343,4 +392,93 @@ class JsonldArticleTest extends TestCase {
|
||||
// Still exactly 2 pieces — no new nodes injected.
|
||||
$this->assertCount( 2, $result );
|
||||
}
|
||||
|
||||
// ── emit_standalone_fallback ──────────────────────────────────────────
|
||||
|
||||
public function test_standalone_fallback_emits_nothing_for_untriggered_post(): void {
|
||||
$GLOBALS['pr_test_is_singular'] = true;
|
||||
$GLOBALS['pr_test_singular_type'] = 'post';
|
||||
$GLOBALS['pr_test_the_id'] = 3;
|
||||
$this->set_meta( 3, array() ); // no _prab_schema_version.
|
||||
|
||||
$emitter = new PR_Core_Jsonld_Article();
|
||||
ob_start();
|
||||
$emitter->emit_standalone_fallback();
|
||||
$output = ob_get_clean();
|
||||
|
||||
$this->assertSame( '', $output, 'Untriggered post must produce no output' );
|
||||
}
|
||||
|
||||
public function test_standalone_fallback_emits_valid_graph_for_triggered_post(): void {
|
||||
$GLOBALS['pr_test_is_singular'] = true;
|
||||
$GLOBALS['pr_test_singular_type'] = 'post';
|
||||
$GLOBALS['pr_test_the_id'] = 4;
|
||||
$GLOBALS['pr_test_posts'][4] = new WP_Post( array(
|
||||
'ID' => 4,
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'publish',
|
||||
'post_title' => 'BPC-157 Article',
|
||||
) );
|
||||
$this->set_meta( 4, array(
|
||||
'_prab_schema_version' => '1',
|
||||
'_prab_review_mode' => 'editorial-system',
|
||||
'_prab_reviewed_at' => '2026-06-20T10:00:00+00:00',
|
||||
'_prab_citations' => wp_json_encode( array(
|
||||
array( 'url' => 'https://example.com/study', 'title' => 'Study One', 'doi' => '10.1234/abc' ),
|
||||
) ),
|
||||
) );
|
||||
|
||||
$emitter = new PR_Core_Jsonld_Article();
|
||||
ob_start();
|
||||
$emitter->emit_standalone_fallback();
|
||||
$output = ob_get_clean();
|
||||
|
||||
// Must contain a script tag.
|
||||
$this->assertStringContainsString( '<script type="application/ld+json">', $output );
|
||||
$this->assertStringContainsString( '</script>', $output );
|
||||
|
||||
// Extract JSON between script tags.
|
||||
preg_match( '#<script[^>]*>(.*?)</script>#s', $output, $matches );
|
||||
$this->assertNotEmpty( $matches[1], 'JSON body found inside script tag' );
|
||||
|
||||
$decoded = json_decode( $matches[1], true );
|
||||
$this->assertIsArray( $decoded, 'Output is valid JSON' );
|
||||
$this->assertSame( 'https://schema.org', $decoded['@context'] );
|
||||
$this->assertArrayHasKey( '@graph', $decoded );
|
||||
|
||||
// Collect types.
|
||||
$types = array_column( $decoded['@graph'], '@type' );
|
||||
$this->assertContains( 'MedicalWebPage', $types, '@graph contains MedicalWebPage' );
|
||||
$this->assertContains( 'Article', $types, '@graph contains Article' );
|
||||
|
||||
// Article must have citation with ScholarlyArticle (DOI present).
|
||||
$article_node = null;
|
||||
foreach ( $decoded['@graph'] as $piece ) {
|
||||
if ( 'Article' === ( $piece['@type'] ?? '' ) ) {
|
||||
$article_node = $piece;
|
||||
}
|
||||
}
|
||||
$this->assertNotNull( $article_node );
|
||||
$this->assertArrayHasKey( 'citation', $article_node );
|
||||
$this->assertSame( 'ScholarlyArticle', $article_node['citation'][0]['@type'] );
|
||||
}
|
||||
public function test_standalone_fallback_suppressed_when_yoast_active(): void {
|
||||
// YoastSEO() is defined here so it runs LAST — once defined it persists for
|
||||
// the whole PHPUnit process and would suppress the output assertions above.
|
||||
if ( ! function_exists( 'YoastSEO' ) ) {
|
||||
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound
|
||||
eval( 'function YoastSEO() { return new stdClass(); }' );
|
||||
}
|
||||
$GLOBALS['pr_test_is_singular'] = true;
|
||||
$GLOBALS['pr_test_singular_type'] = 'post';
|
||||
$GLOBALS['pr_test_the_id'] = 1;
|
||||
$this->set_meta( 1, array( '_prab_schema_version' => '1' ) );
|
||||
|
||||
$emitter = new PR_Core_Jsonld_Article();
|
||||
ob_start();
|
||||
$emitter->emit_standalone_fallback();
|
||||
$output = ob_get_clean();
|
||||
|
||||
$this->assertSame( '', $output, 'Yoast-active path must produce no output' );
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user