From 52fb724ee0cd915e0214ba42547343bbe22e8ba6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 12:09:53 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20MQA-based=20quality=20scoring=20?= =?UTF-8?q?=E2=80=94=20Phase=201=20(v2.13.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the simple 0–100 completeness score with the EU Metadata Quality Assessment (MQA) model from data.europa.eu: 5 FAIR dimensions, 405 points, 4 rating bands. See docs/MQA-KONZEPT.md. - config/mqa-metrics.php: new single source of truth (21 metrics, 405 pts, per-metric type: present | vocab | reachable | shacl) - ODW_Quality rewritten to compute the MQA score: all "present?" metrics are scored (offline); vocab/reachable/shacl are wired as "not assessed" and excluded from the assessable maximum. Rating bands are scaled proportionally to the assessable maximum (351/221/121 of 405). - Meta box shows per-dimension scores + metric status; admin column keeps working via a derived 0–100 score and shows the MQA rating label. - JSON-LD odw:qualityScore now exposes MQA points (of 405), rating and the per-dimension breakdown, referencing the MQA methodology URL. Backward compatibility - Publish validation (config/dcat-ap-fields.php) is untouched. - Derived _odw_quality_score / _odw_quality_level kept for the list column and sorting; the full MQA result is stored in _odw_mqa. i18n: 33 new MQA UI strings added to .po/.pot; en_US.mo recompiled. Version 2.13.0. Tests: 148 pass, PHPStan clean, PHPCS exit 0. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JB1xUQM892bVZ4Yv3MZjvq --- CHANGELOG.md | 34 ++ README.md | 2 +- assets/css/admin.css | 17 + config/mqa-metrics.php | 261 +++++++++ config/phpstan.neon | 2 - includes/class-admin.php | 3 +- includes/class-quality.php | 670 +++++++++++++--------- languages/open-data-wizard-en_US.mo | Bin 45808 -> 48517 bytes languages/open-data-wizard-en_US.po | 840 ++++++++++++++++------------ open-data-wizard.php | 4 +- tests/test-quality.php | 219 +++++--- 11 files changed, 1345 insertions(+), 707 deletions(-) create mode 100644 config/mqa-metrics.php diff --git a/CHANGELOG.md b/CHANGELOG.md index dc8c5d7..2ca78d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/spec/v2.0.0.ht --- +## [2.13.0] — 2026-07-07 + +Neues Qualitäts-Scoring nach der EU-MQA-Methodik (Phase 1). Siehe `docs/MQA-KONZEPT.md`. + +### ✨ Changed — Qualitätsprüfung auf MQA umgestellt +- Die Qualitätsprüfung folgt jetzt dem **Metadata Quality Assessment (MQA)** von data.europa.eu: + **5 FAIR-Dimensionen** (Auffindbarkeit, Zugänglichkeit, Interoperabilität, Wiederverwendbarkeit, + Kontext), **405 Punkte**, **4 Bewertungsstufen** (Ausgezeichnet / Gut / Ausreichend / Mangelhaft). +- Die Metriken stehen in der neuen Single-Source-of-Truth `config/mqa-metrics.php`. +- Die Meta-Box zeigt die Bewertung pro Dimension inkl. Punkte und Metrik-Status. +- Das JSON-LD/REST-Feld `odw:qualityScore` weist nun MQA-Punkte (von 405), die Bewertungsstufe und + die Dimensions-Aufschlüsselung aus (verweist auf die MQA-Methodik-URL). + +### 🔧 Phasenumfang +- **Phase 1 (dieses Release):** Alle „gesetzt?"-Metriken werden bewertet (offline). +- Vokabular-, URL-Erreichbarkeits- und DCAT-AP-SHACL-Metriken sind als **„nicht bewertet"** verdrahtet + und werden aus dem bewertbaren Maximum herausgerechnet; die Bewertungsstufen werden **proportional** + auf das bewertbare Maximum skaliert. Diese Metriken folgen in Phase 2 (Vokabular) und Phase 3 + (Netzwerk/SHACL). + +### ♻️ Abwärtskompatibilität +- Die Publish-Validierung (`config/dcat-ap-fields.php`) bleibt unverändert. +- Der abgeleitete 0–100-Score und das Level bleiben in `_odw_quality_score` / `_odw_quality_level` + erhalten (Admin-Listenspalte, Sortierung). Das vollständige MQA-Ergebnis liegt in `_odw_mqa`. + +### 🌍 i18n +- 33 neue MQA-UI-Strings in `.po`/`.pot` ergänzt und `en_US.mo` neu kompiliert. + +### ✅ Tests +- Neue Tests für Bewertungsstufen (proportionale Schwellen), Metrik-Summen je Dimension (405 gesamt) + und die MQA-Persistierung/JSON-LD-Ausgabe. + +--- + ## [2.12.0] — 2026-06-28 UX-Feinschliff und Vervollständigung der optionalen DCAT-AP-3.0-Felder. diff --git a/README.md b/README.md index 10ab821..b160337 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ![PHP Version](https://img.shields.io/badge/PHP-%3E%3D%208.1-8892BF?style=flat-square&logo=php&logoColor=white) ![WordPress](https://img.shields.io/badge/WordPress-compatible-21759B?style=flat-square&logo=wordpress&logoColor=white) ![DCAT-AP](https://img.shields.io/badge/DCAT--AP-3.0-brightgreen?style=flat-square) -![Version](https://img.shields.io/badge/Version-2.12.0-brightgreen?style=flat-square) +![Version](https://img.shields.io/badge/Version-2.13.0-brightgreen?style=flat-square) ![PRs Welcome](https://img.shields.io/badge/PRs-willkommen-brightgreen?style=flat-square) 📖 [Dokumentation](DOCUMENTATION.md) · 📐 [Technische Spezifikation](TECHNICAL-SPEC.md) · 📝 [Changelog](CHANGELOG.md) · 🛡️ [Security](SECURITY.md) · ⚖️ [Lizenz](LICENSE) diff --git a/assets/css/admin.css b/assets/css/admin.css index 6d69d32..54f2d00 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -344,6 +344,23 @@ .odw-quality-fail .odw-quality-col-status { color: var(--odw-color-quality-low-dot); } .odw-quality-fail { color: var(--odw-color-text-muted); } +/* MQA: not-assessed metrics (network / vocabulary / SHACL — pending phases) */ +.odw-quality-notassessed { color: var(--odw-color-text-muted); } +.odw-quality-notassessed .odw-quality-col-status { color: var(--odw-color-border); } + +/* MQA: per-dimension score in the section header */ +.odw-quality-dim-score { + float: right; + font-weight: 400; + color: var(--odw-color-text-muted); +} + +.odw-quality-of-max { + font-weight: 400; + color: var(--odw-color-text-muted); + font-size: 12px; +} + /* Footer */ .odw-quality-footer { margin-top: 12px; diff --git a/config/mqa-metrics.php b/config/mqa-metrics.php new file mode 100644 index 0000000..eace9bd --- /dev/null +++ b/config/mqa-metrics.php @@ -0,0 +1,261 @@ + 'keyword', + 'dimension' => 'findability', + 'dcat_prop' => 'dcat:keyword', + 'label' => __( 'Schlüsselwörter (dcat:keyword)', 'open-data-wizard' ), + 'points' => 30, + 'type' => 'present', + 'check' => 'keyword', + ), + array( + 'key' => 'theme', + 'dimension' => 'findability', + 'dcat_prop' => 'dcat:theme', + 'label' => __( 'Kategorien (dcat:theme)', 'open-data-wizard' ), + 'points' => 30, + 'type' => 'present', + 'check' => 'theme', + ), + array( + 'key' => 'spatial', + 'dimension' => 'findability', + 'dcat_prop' => 'dct:spatial', + 'label' => __( 'Räumliche Abdeckung (dct:spatial)', 'open-data-wizard' ), + 'points' => 20, + 'type' => 'present', + 'check' => 'spatial', + ), + array( + 'key' => 'temporal', + 'dimension' => 'findability', + 'dcat_prop' => 'dct:temporal', + 'label' => __( 'Zeitliche Abdeckung (dct:temporal)', 'open-data-wizard' ), + 'points' => 20, + 'type' => 'present', + 'check' => 'temporal', + ), + + // ------------------------------------------------------------------------- + // Zugänglichkeit (max. 100) + // ------------------------------------------------------------------------- + array( + 'key' => 'access_url_reachable', + 'dimension' => 'accessibility', + 'dcat_prop' => 'dcat:accessURL', + 'label' => __( 'AccessURL erreichbar (dcat:accessURL)', 'open-data-wizard' ), + 'points' => 50, + 'type' => 'reachable', + 'check' => 'access_url', + ), + array( + 'key' => 'download_url', + 'dimension' => 'accessibility', + 'dcat_prop' => 'dcat:downloadURL', + 'label' => __( 'DownloadURL gesetzt (dcat:downloadURL)', 'open-data-wizard' ), + 'points' => 20, + 'type' => 'present', + 'check' => 'download_url', + ), + array( + 'key' => 'download_url_reachable', + 'dimension' => 'accessibility', + 'dcat_prop' => 'dcat:downloadURL', + 'label' => __( 'DownloadURL erreichbar (dcat:downloadURL)', 'open-data-wizard' ), + 'points' => 30, + 'type' => 'reachable', + 'check' => 'download_url', + ), + + // ------------------------------------------------------------------------- + // Interoperabilität (max. 110) + // ------------------------------------------------------------------------- + array( + 'key' => 'format', + 'dimension' => 'interoperability', + 'dcat_prop' => 'dct:format', + 'label' => __( 'Format gesetzt (dct:format)', 'open-data-wizard' ), + 'points' => 20, + 'type' => 'present', + 'check' => 'format', + ), + array( + 'key' => 'media_type', + 'dimension' => 'interoperability', + 'dcat_prop' => 'dcat:mediaType', + 'label' => __( 'Media-Type gesetzt (dcat:mediaType)', 'open-data-wizard' ), + 'points' => 10, + 'type' => 'present', + 'check' => 'media_type', + ), + array( + 'key' => 'format_vocab', + 'dimension' => 'interoperability', + 'dcat_prop' => 'dct:format', + 'label' => __( 'Format/Media-Type aus Vokabular', 'open-data-wizard' ), + 'points' => 10, + 'type' => 'vocab', + 'check' => 'format_vocab', + ), + array( + 'key' => 'format_nonproprietary', + 'dimension' => 'interoperability', + 'dcat_prop' => 'dct:format', + 'label' => __( 'Nicht-proprietäres Format', 'open-data-wizard' ), + 'points' => 20, + 'type' => 'vocab', + 'check' => 'format_nonproprietary', + ), + array( + 'key' => 'format_machine_readable', + 'dimension' => 'interoperability', + 'dcat_prop' => 'dct:format', + 'label' => __( 'Maschinenlesbares Format', 'open-data-wizard' ), + 'points' => 20, + 'type' => 'vocab', + 'check' => 'format_machine_readable', + ), + array( + 'key' => 'dcat_ap_compliance', + 'dimension' => 'interoperability', + 'dcat_prop' => 'dcat-ap', + 'label' => __( 'DCAT-AP-Konformität (SHACL)', 'open-data-wizard' ), + 'points' => 30, + 'type' => 'shacl', + 'check' => 'shacl', + ), + + // ------------------------------------------------------------------------- + // Wiederverwendbarkeit (max. 75) + // ------------------------------------------------------------------------- + array( + 'key' => 'license', + 'dimension' => 'reusability', + 'dcat_prop' => 'dct:license', + 'label' => __( 'Lizenz gesetzt (dct:license)', 'open-data-wizard' ), + 'points' => 20, + 'type' => 'present', + 'check' => 'license', + ), + array( + 'key' => 'license_vocab', + 'dimension' => 'reusability', + 'dcat_prop' => 'dct:license', + 'label' => __( 'Lizenz aus Vokabular', 'open-data-wizard' ), + 'points' => 10, + 'type' => 'vocab', + 'check' => 'license_vocab', + ), + array( + 'key' => 'access_rights', + 'dimension' => 'reusability', + 'dcat_prop' => 'dct:accessRights', + 'label' => __( 'Zugriffsrechte gesetzt (dct:accessRights)', 'open-data-wizard' ), + 'points' => 10, + 'type' => 'present', + 'check' => 'access_rights', + ), + array( + 'key' => 'access_rights_vocab', + 'dimension' => 'reusability', + 'dcat_prop' => 'dct:accessRights', + 'label' => __( 'Zugriffsrechte aus Vokabular', 'open-data-wizard' ), + 'points' => 5, + 'type' => 'vocab', + 'check' => 'access_rights_vocab', + ), + array( + 'key' => 'contact_point', + 'dimension' => 'reusability', + 'dcat_prop' => 'dcat:contactPoint', + 'label' => __( 'Kontaktpunkt (dcat:contactPoint)', 'open-data-wizard' ), + 'points' => 20, + 'type' => 'present', + 'check' => 'contact_point', + ), + array( + 'key' => 'publisher', + 'dimension' => 'reusability', + 'dcat_prop' => 'dct:publisher', + 'label' => __( 'Herausgeber (dct:publisher)', 'open-data-wizard' ), + 'points' => 10, + 'type' => 'present', + 'check' => 'publisher', + ), + + // ------------------------------------------------------------------------- + // Kontext (max. 20) + // ------------------------------------------------------------------------- + array( + 'key' => 'rights', + 'dimension' => 'contextuality', + 'dcat_prop' => 'dct:rights', + 'label' => __( 'Rechte (dct:rights)', 'open-data-wizard' ), + 'points' => 5, + 'type' => 'present', + 'check' => 'rights', + ), + array( + 'key' => 'byte_size', + 'dimension' => 'contextuality', + 'dcat_prop' => 'dcat:byteSize', + 'label' => __( 'Dateigröße (dcat:byteSize)', 'open-data-wizard' ), + 'points' => 5, + 'type' => 'present', + 'check' => 'byte_size', + ), + array( + 'key' => 'issued', + 'dimension' => 'contextuality', + 'dcat_prop' => 'dct:issued', + 'label' => __( 'Ausstellungsdatum (dct:issued)', 'open-data-wizard' ), + 'points' => 5, + 'type' => 'present', + 'check' => 'issued', + ), + array( + 'key' => 'modified', + 'dimension' => 'contextuality', + 'dcat_prop' => 'dct:modified', + 'label' => __( 'Änderungsdatum (dct:modified)', 'open-data-wizard' ), + 'points' => 5, + 'type' => 'present', + 'check' => 'modified', + ), +); +// Summe: 100 + 100 + 110 + 75 + 20 = 405. diff --git a/config/phpstan.neon b/config/phpstan.neon index 1836c80..5d15608 100644 --- a/config/phpstan.neon +++ b/config/phpstan.neon @@ -55,5 +55,3 @@ parameters: # PHPDoc type strictness causes false positives with WordPress types - identifier: function.impossibleType message: '/Call to function is_wp_error.*will always evaluate/' - - - identifier: identical.alwaysTrue diff --git a/includes/class-admin.php b/includes/class-admin.php index 0f502d0..42f9cd3 100644 --- a/includes/class-admin.php +++ b/includes/class-admin.php @@ -153,7 +153,8 @@ public static function render_column( string $column, int $post_id ): void { } else { $level = $quality['level']; $score = $quality['score']; - $label = ODW_Quality::get_level_label( $level ); + $rating = (string) ( $quality['rating'] ?? '' ); + $label = '' !== $rating ? ODW_Quality::get_rating_label( $rating ) : ODW_Quality::get_level_label( $level ); $title_attr = sprintf( '%s · %d/100 %s', $label, $score, __( 'Punkte', 'open-data-wizard' ) ); printf( ' %d', diff --git a/includes/class-quality.php b/includes/class-quality.php index 2dac924..a6aafdc 100644 --- a/includes/class-quality.php +++ b/includes/class-quality.php @@ -1,15 +1,15 @@ + * @return array */ - public static function get_indicators(): array { - if ( class_exists( 'ODW_Fields' ) ) { - $defs = ODW_Fields::load_field_definitions(); - if ( ! empty( $defs ) ) { - return $defs; - } + public static function get_metrics(): array { + $file = ODW_PLUGIN_DIR . 'config/mqa-metrics.php'; + + if ( ! file_exists( $file ) ) { + return array(); } - // Fallback when ODW_Fields is not available (e.g. unit tests). + $data = include $file; + return is_array( $data ) ? $data : array(); + } + + /** + * Menschenlesbare Labels der Dimensionen. + * + * @return array + */ + public static function get_dimension_labels(): array { return array( - array( - 'key' => 'title', - 'label' => __( 'Titel (dct:title)', 'open-data-wizard' ), - 'points' => 10, - 'required' => true, - ), - array( - 'key' => 'description', - 'label' => __( 'Beschreibung (dct:description)', 'open-data-wizard' ), - 'points' => 10, - 'required' => true, - ), - array( - 'key' => 'publisher', - 'label' => __( 'Herausgeber (dct:publisher)', 'open-data-wizard' ), - 'points' => 10, - 'required' => true, - ), - array( - 'key' => 'license', - 'label' => __( 'Lizenz (dct:license)', 'open-data-wizard' ), - 'points' => 10, - 'required' => true, - ), - array( - 'key' => 'distribution', - 'label' => __( 'Distribution mit URL (dcat:accessURL)', 'open-data-wizard' ), - 'points' => 15, - 'required' => true, - ), - array( - 'key' => 'language', - 'label' => __( 'Sprache (dct:language)', 'open-data-wizard' ), - 'points' => 10, - 'required' => false, - ), - array( - 'key' => 'keywords', - 'label' => __( 'Schlagworte (dcat:keyword)', 'open-data-wizard' ), - 'points' => 10, - 'required' => false, - ), - array( - 'key' => 'theme', - 'label' => __( 'Thema (dcat:theme)', 'open-data-wizard' ), - 'points' => 10, - 'required' => false, - ), - array( - 'key' => 'issued', - 'label' => __( 'Veröffentlichungsdatum (dct:issued)', 'open-data-wizard' ), - 'points' => 10, - 'required' => false, - ), - array( - 'key' => 'dist_format', - 'label' => __( 'Format der Distribution (dct:format)', 'open-data-wizard' ), - 'points' => 5, - 'required' => false, - ), + 'findability' => __( 'Auffindbarkeit', 'open-data-wizard' ), + 'accessibility' => __( 'Zugänglichkeit', 'open-data-wizard' ), + 'interoperability' => __( 'Interoperabilität', 'open-data-wizard' ), + 'reusability' => __( 'Wiederverwendbarkeit', 'open-data-wizard' ), + 'contextuality' => __( 'Kontext', 'open-data-wizard' ), ); } @@ -142,10 +108,10 @@ public static function get_indicators(): array { // ------------------------------------------------------------------------- /** - * Berechnet den Qualitätsscore für einen Datensatz. + * Berechnet den MQA-Qualitätsscore für einen Datensatz. * * @param int $post_id Dataset post ID. - * @return array{score: int, level: string, indicators: array, calculated_at: string} + * @return array MQA-Ergebnis inkl. abgeleitetem 0–100-Score und Legacy-Level. */ public static function calculate( int $post_id ): array { $post = get_post( $post_id ); @@ -154,133 +120,200 @@ public static function calculate( int $post_id ): array { return self::empty_result(); } - $total = 0; - $breakdown = array(); + $dimensions = array(); + foreach ( self::DIMENSIONS as $dim ) { + $dimensions[ $dim ] = array( + 'achieved' => 0, + 'assessable' => 0, + 'max' => 0, + ); + } - foreach ( self::get_indicators() as $indicator ) { - $passed = self::check_indicator( $indicator['key'], $post ); - $earned = $passed ? $indicator['points'] : 0; - $total += $earned; - - $breakdown[ $indicator['key'] ] = array( - 'label' => $indicator['label'], - 'points' => $indicator['points'], - 'earned' => $earned, - 'passed' => $passed, - 'required' => $indicator['required'], + $metrics = array(); + + foreach ( self::get_metrics() as $metric ) { + $dim = $metric['dimension']; + if ( ! isset( $dimensions[ $dim ] ) ) { + $dimensions[ $dim ] = array( + 'achieved' => 0, + 'assessable' => 0, + 'max' => 0, + ); + } + + $points = (int) $metric['points']; + $dimensions[ $dim ]['max'] += $points; + + $passed = self::evaluate_metric( $metric, $post ); + + if ( null === $passed ) { + $status = 'not_assessed'; + } else { + $dimensions[ $dim ]['assessable'] += $points; + if ( $passed ) { + $dimensions[ $dim ]['achieved'] += $points; + } + $status = $passed ? 'passed' : 'failed'; + } + + $metrics[ $metric['key'] ] = array( + 'label' => $metric['label'], + 'dimension' => $dim, + 'points' => $points, + 'status' => $status, ); } - // Clamp into the documented 0–100 range so a changed config can never - // produce an out-of-range score. - $total = max( 0, min( 100, $total ) ); + $achieved = (int) array_sum( array_column( $dimensions, 'achieved' ) ); + $assessable = (int) array_sum( array_column( $dimensions, 'assessable' ) ); + $rating = self::get_rating( $achieved, $assessable ); return array( - 'score' => $total, - 'level' => self::get_level( $total ), - 'indicators' => $breakdown, + 'achieved' => $achieved, + 'assessable' => $assessable, + 'max' => self::MQA_MAX, + 'rating' => $rating, + 'dimensions' => $dimensions, + 'metrics' => $metrics, 'calculated_at' => current_time( 'Y-m-d H:i:s' ), + // Abwärtskompatibilität für Admin-Spalte, Sortierung und Alt-Konsumenten. + 'score' => $assessable > 0 ? (int) round( $achieved / $assessable * 100 ) : 0, + 'level' => self::rating_to_level( $rating ), ); } /** - * Sum of points for all required indicators — the score reached when exactly - * the required fields are filled. Computed from the active config so the level - * thresholds stay correct if the point values change. + * Wertet eine einzelne Metrik aus. * - * @return int Required-only score threshold. + * @param array $metric Metrik-Definition. + * @param \WP_Post $post Dataset post object. + * @return bool|null true = erfüllt, false = nicht erfüllt, null = nicht bewertet (Vokabular/Netzwerk/SHACL). */ - private static function get_required_only_score(): int { - $sum = 0; - foreach ( self::get_indicators() as $indicator ) { - if ( ! empty( $indicator['required'] ) ) { - $sum += (int) $indicator['points']; - } + private static function evaluate_metric( array $metric, \WP_Post $post ): ?bool { + if ( 'present' !== ( $metric['type'] ?? '' ) ) { + // Vokabular-, Erreichbarkeits- und SHACL-Prüfungen folgen in späteren Phasen. + return null; } - $threshold = $sum > 0 ? $sum : self::REQUIRED_ONLY_SCORE; - - // Keep the threshold within the clamped 0–100 score range so get_level() - // stays consistent even if a filter pushes the required points above 100. - return min( 100, $threshold ); + return self::check_metric( (string) $metric['check'], $post ); } /** - * Prüft einen einzelnen Indikator am WP_Post-Objekt. + * „Ist die Eigenschaft gesetzt?"-Prüfung je Metrik. * - * @param string $key Indicator key (e.g. 'title', 'license'). - * @param \WP_Post $post Dataset post object. - * @return bool True when the indicator passes. + * @param string $check Check-Schlüssel aus der Metrik-Definition. + * @param \WP_Post $post Dataset post object. + * @return bool True wenn die Eigenschaft im Datensatz vorhanden ist. */ - private static function check_indicator( string $key, \WP_Post $post ): bool { - switch ( $key ) { - case 'title': - return '' !== trim( $post->post_title ); + private static function check_metric( string $check, \WP_Post $post ): bool { + $id = $post->ID; + + switch ( $check ) { + case 'keyword': + $raw = (string) carbon_get_post_meta( $id, 'odw_keywords' ); + $parts = preg_split( '/\r?\n/', $raw ); + $keywords = is_array( $parts ) ? array_filter( array_map( 'trim', $parts ) ) : array(); + return ! empty( $keywords ); - case 'description': - return '' !== trim( (string) carbon_get_post_meta( $post->ID, 'odw_description' ) ); + case 'theme': + return '' !== trim( (string) carbon_get_post_meta( $id, 'odw_theme' ) ); - case 'publisher': - return '' !== trim( (string) carbon_get_post_meta( $post->ID, 'odw_publisher' ) ); + case 'spatial': + return '' !== trim( (string) carbon_get_post_meta( $id, 'odw_spatial' ) ); + + case 'temporal': + return '' !== trim( (string) carbon_get_post_meta( $id, 'odw_temporal_start' ) ) + || '' !== trim( (string) carbon_get_post_meta( $id, 'odw_temporal_end' ) ); + + case 'download_url': + return '' !== trim( (string) carbon_get_post_meta( $id, 'odw_download_url' ) ); + + case 'format': + return '' !== trim( (string) carbon_get_post_meta( $id, 'odw_format' ) ); + + case 'media_type': + return '' !== trim( (string) carbon_get_post_meta( $id, 'odw_media_type' ) ); case 'license': - $lic = (string) carbon_get_post_meta( $post->ID, 'odw_license' ); + $lic = (string) carbon_get_post_meta( $id, 'odw_license' ); if ( '' !== $lic && 'sonstige' !== $lic ) { return true; } if ( 'sonstige' === $lic ) { - return '' !== (string) carbon_get_post_meta( $post->ID, 'odw_license_custom' ); + return '' !== trim( (string) carbon_get_post_meta( $id, 'odw_license_custom' ) ); } return false; - case 'distribution': - return '' !== (string) carbon_get_post_meta( $post->ID, 'odw_access_url' ); + case 'access_rights': + return '' !== trim( (string) carbon_get_post_meta( $id, 'odw_access_rights' ) ); - case 'language': - return '' !== trim( (string) carbon_get_post_meta( $post->ID, 'odw_language' ) ); + case 'contact_point': + return '' !== trim( (string) carbon_get_post_meta( $id, 'odw_contact_name' ) ) + || '' !== trim( (string) carbon_get_post_meta( $id, 'odw_contact_email' ) ) + || '' !== trim( (string) carbon_get_post_meta( $id, 'odw_contact_url' ) ); - case 'keywords': - $raw = (string) carbon_get_post_meta( $post->ID, 'odw_keywords' ); - $keywords = array_filter( array_map( 'trim', preg_split( '/\r?\n/', $raw ) ) ); - return ! empty( $keywords ); + case 'publisher': + return '' !== trim( (string) carbon_get_post_meta( $id, 'odw_publisher' ) ); - case 'theme': - return '' !== trim( (string) carbon_get_post_meta( $post->ID, 'odw_theme' ) ); + case 'rights': + return '' !== trim( (string) carbon_get_post_meta( $id, 'odw_dist_rights' ) ); + + case 'byte_size': + return (int) carbon_get_post_meta( $id, 'odw_byte_size' ) > 0; case 'issued': - return '' !== trim( (string) carbon_get_post_meta( $post->ID, 'odw_issued' ) ); + return '' !== trim( (string) carbon_get_post_meta( $id, 'odw_issued' ) ); - case 'dist_format': - return '' !== (string) carbon_get_post_meta( $post->ID, 'odw_format' ); + case 'modified': + return '' !== trim( (string) carbon_get_post_meta( $id, 'odw_modified' ) ) + || '' !== trim( (string) get_post_meta( $id, '_odw_modified', true ) ); } return false; } /** - * Ermittelt das Qualitätslevel aus dem Score. - * - * 100 → Perfekt (alle Felder ausgefüllt) - * 56–99 → Gut (alle Pflichtfelder + einige optionale) - * REQUIRED_ONLY → Ausreichend (genau alle Pflichtfelder, keine optionalen) - * < REQUIRED → Verbesserungsbedarf (Pflichtfelder fehlen) + * Ermittelt die MQA-Bewertungsstufe aus erreichten/bewertbaren Punkten. + * Die MQA-Schwellen (351/221/121 von 405) werden proportional auf das + * bewertbare Maximum skaliert. * - * @param int $score Numeric score 0–100. - * @return string One of LEVEL_PERFECT, LEVEL_HIGH, LEVEL_SUFFICIENT, LEVEL_LOW. + * @param int $achieved Erreichte Punkte. + * @param int $assessable Bewertbares Maximum. + * @return string Eine der RATING_* Konstanten. */ - public static function get_level( int $score ): string { - $required_only = self::get_required_only_score(); + public static function get_rating( int $achieved, int $assessable ): string { + if ( $assessable <= 0 ) { + return self::RATING_BAD; + } - if ( 100 === $score ) { - return self::LEVEL_PERFECT; + $ratio = $achieved / $assessable; + + if ( $ratio >= self::RATING_EXCELLENT_RATIO ) { + return self::RATING_EXCELLENT; } - if ( $score > $required_only ) { - return self::LEVEL_HIGH; + if ( $ratio >= self::RATING_GOOD_RATIO ) { + return self::RATING_GOOD; } - if ( $required_only === $score ) { - return self::LEVEL_SUFFICIENT; + if ( $ratio >= self::RATING_SUFFICIENT_RATIO ) { + return self::RATING_SUFFICIENT; } - return self::LEVEL_LOW; + return self::RATING_BAD; + } + + /** + * Bildet eine MQA-Bewertungsstufe auf eine Legacy-Level-Konstante ab + * (für Admin-Spalte und Badge-CSS). + * + * @param string $rating Eine der RATING_* Konstanten. + * @return string Eine der LEVEL_* Konstanten. + */ + private static function rating_to_level( string $rating ): string { + return array( + self::RATING_EXCELLENT => self::LEVEL_PERFECT, + self::RATING_GOOD => self::LEVEL_HIGH, + self::RATING_SUFFICIENT => self::LEVEL_SUFFICIENT, + self::RATING_BAD => self::LEVEL_LOW, + )[ $rating ] ?? self::LEVEL_LOW; } // ------------------------------------------------------------------------- @@ -291,23 +324,16 @@ public static function get_level( int $score ): string { * Holt gespeicherte Qualitätsdaten aus Post-Meta. * * @param int $post_id Dataset post ID. - * @return array{score: int, level: string, indicators: array, calculated_at: string} + * @return array */ public static function get( int $post_id ): array { - $level = (string) get_post_meta( $post_id, '_odw_quality_level', true ); + $stored = get_post_meta( $post_id, '_odw_mqa', true ); - if ( '' === $level ) { + if ( ! is_array( $stored ) || empty( $stored['rating'] ) ) { return self::empty_result(); } - $indicators = get_post_meta( $post_id, '_odw_quality_indicators', true ); - - return array( - 'score' => (int) get_post_meta( $post_id, '_odw_quality_score', true ), - 'level' => $level, - 'indicators' => is_array( $indicators ) ? $indicators : array(), - 'calculated_at' => (string) get_post_meta( $post_id, '_odw_quality_calculated_at', true ), - ); + return $stored; } /** @@ -317,10 +343,12 @@ public static function get( int $post_id ): array { * @param array $result Result array from calculate(). */ public static function store( int $post_id, array $result ): void { - update_post_meta( $post_id, '_odw_quality_score', $result['score'] ); - update_post_meta( $post_id, '_odw_quality_level', $result['level'] ); - update_post_meta( $post_id, '_odw_quality_indicators', $result['indicators'] ); - update_post_meta( $post_id, '_odw_quality_calculated_at', $result['calculated_at'] ); + update_post_meta( $post_id, '_odw_mqa', $result ); + + // Abwärtskompatible Skalar-Meta für Admin-Spalte + Sortierung. + update_post_meta( $post_id, '_odw_quality_score', $result['score'] ?? 0 ); + update_post_meta( $post_id, '_odw_quality_level', $result['level'] ?? '' ); + update_post_meta( $post_id, '_odw_quality_calculated_at', $result['calculated_at'] ?? '' ); } /** @@ -337,13 +365,11 @@ public static function recalculate_on_save( int $post_id ): void { return; } - // Now hooked on the generic save_post, so guard the post type explicitly. if ( 'odw_dataset' !== get_post_type( $post_id ) ) { return; } - $result = self::calculate( $post_id ); - self::store( $post_id, $result ); + self::store( $post_id, self::calculate( $post_id ) ); } // ------------------------------------------------------------------------- @@ -351,25 +377,37 @@ public static function recalculate_on_save( int $post_id ): void { // ------------------------------------------------------------------------- /** - * Hängt Qualitätsdaten an den JSON-LD Dataset-Array an. + * Hängt die MQA-Qualitätsdaten an den JSON-LD Dataset-Array an. * - * @param array $dataset Der JSON-LD Array. - * @param int $post_id Post-ID. + * @param array $dataset Der JSON-LD Array. + * @param int $post_id Post-ID. * @return array */ public static function append_to_jsonld( array $dataset, int $post_id ): array { $quality = self::get( $post_id ); - if ( '' === $quality['level'] ) { + if ( empty( $quality['rating'] ) ) { return $dataset; } + $dimensions = array(); + foreach ( (array) ( $quality['dimensions'] ?? array() ) as $dim => $data ) { + $dimensions[ $dim ] = array( + 'odw:score' => (int) ( $data['achieved'] ?? 0 ), + 'odw:assessable' => (int) ( $data['assessable'] ?? 0 ), + 'odw:maxScore' => (int) ( $data['max'] ?? 0 ), + ); + } + $dataset['odw:qualityScore'] = array( '@type' => 'odw:QualityScore', - 'odw:score' => $quality['score'], - 'odw:maxScore' => 100, - 'odw:level' => $quality['level'], - 'odw:calculatedAt' => $quality['calculated_at'], + 'odw:methodology' => 'https://data.europa.eu/mqa/methodology', + 'odw:score' => (int) ( $quality['achieved'] ?? 0 ), + 'odw:assessable' => (int) ( $quality['assessable'] ?? 0 ), + 'odw:maxScore' => (int) ( $quality['max'] ?? self::MQA_MAX ), + 'odw:rating' => (string) $quality['rating'], + 'odw:dimensions' => $dimensions, + 'odw:calculatedAt' => (string) ( $quality['calculated_at'] ?? '' ), ); return $dataset; @@ -385,7 +423,7 @@ public static function append_to_jsonld( array $dataset, int $post_id ): array { public static function register_meta_box(): void { add_meta_box( 'odw-quality-report', - __( 'Qualitätsprüfung', 'open-data-wizard' ), + __( 'Qualitätsprüfung (MQA)', 'open-data-wizard' ), array( self::class, 'render_meta_box' ), 'odw_dataset', 'normal', @@ -394,97 +432,128 @@ public static function register_meta_box(): void { } /** - * Rendert den Inhalt der Qualitäts-Meta-Box. + * Rendert den Inhalt der Qualitäts-Meta-Box (MQA-Dimensionen + Metriken). * * @param \WP_Post $post Current post object. */ public static function render_meta_box( \WP_Post $post ): void { - $quality = self::get( $post->ID ); - $indicators = self::get_indicators(); + $quality = self::get( $post->ID ); - if ( '' === $quality['level'] ) { + if ( empty( $quality['rating'] ) ) { echo '

' . esc_html__( 'Noch keine Qualitätsanalyse vorhanden. Datensatz speichern, um die Prüfung auszuführen.', 'open-data-wizard' ) . '

'; return; } - $score = $quality['score']; - $level = $quality['level']; - $level_label = self::get_level_label( $level ); - // Map 'perfect' to 'high' CSS class so existing styles apply. - $level_class = 'odw-quality--' . ( 'perfect' === $level ? 'high' : $level ); - $stored = $quality['indicators']; + $achieved = (int) ( $quality['achieved'] ?? 0 ); + $assessable = (int) ( $quality['assessable'] ?? 0 ); + $max = (int) ( $quality['max'] ?? self::MQA_MAX ); + $rating = (string) $quality['rating']; + $rating_label = self::get_rating_label( $rating ); + $level_class = 'odw-quality--' . self::rating_to_level( $rating ); + $percent = $assessable > 0 ? (int) round( $achieved / $assessable * 100 ) : 0; + $not_assessed = $max - $assessable; + $dim_labels = self::get_dimension_labels(); + $metrics = (array) ( $quality['metrics'] ?? array() ); ?>

- +

-
+ aria-valuemax="">
- / 100 + + + ' . esc_html( + sprintf( + /* translators: %d: MQA maximum score */ + __( '(von max. %d)', 'open-data-wizard' ), + $max + ) + ) . ''; + } + ?> +
- +
+ 0 ) : ?> +

+ +

+ + - - - - - - - '; - } elseif ( false === $required && null !== $prev_required ) { - $section = ( $pts >= 10 ) - ? __( 'Empfohlene Felder', 'open-data-wizard' ) - : __( 'Optionale Angaben', 'open-data-wizard' ); - echo ''; + foreach ( self::DIMENSIONS as $dim ) { + $dim_data = $quality['dimensions'][ $dim ] ?? array( + 'achieved' => 0, + 'assessable' => 0, + 'max' => 0, + ); + $d_ach = (int) $dim_data['achieved']; + $d_ass = (int) $dim_data['assessable']; + $d_max = (int) $dim_data['max']; + + echo ''; + + foreach ( $metrics as $m ) { + if ( ( $m['dimension'] ?? '' ) !== $dim ) { + continue; } - $prev_required = $required; - } elseif ( false === $required && $pts < 10 && $prev_pts >= 10 ) { - // Wechsel von Empfohlen zu Optional innerhalb derselben required=false Gruppe. - echo ''; - } - $prev_pts = $pts; - - $status_icon = $passed ? '✓' : '✗'; - $status_class = $passed ? 'odw-quality-pass' : 'odw-quality-fail'; - $pts_display = $passed ? $pts : "0 / {$pts}"; - ?> - - - - - - + + + + + + @@ -495,7 +564,7 @@ public static function render_meta_box( \WP_Post $post ): void { printf( /* translators: %s: Datetime of last quality calculation */ esc_html__( 'Letzte Berechnung: %s · Wird bei jedem Speichern aktualisiert.', 'open-data-wizard' ), - esc_html( $quality['calculated_at'] ) + esc_html( (string) ( $quality['calculated_at'] ?? '' ) ) ); ?>

@@ -508,9 +577,85 @@ public static function render_meta_box( \WP_Post $post ): void { // ------------------------------------------------------------------------- /** - * Returns a human-readable label for a quality level constant. + * Menschenlesbares Label einer MQA-Bewertungsstufe. + * + * @param string $rating Eine der RATING_* Konstanten. + * @return string Übersetztes Label. + */ + public static function get_rating_label( string $rating ): string { + return array( + self::RATING_EXCELLENT => __( 'Ausgezeichnet', 'open-data-wizard' ), + self::RATING_GOOD => __( 'Gut', 'open-data-wizard' ), + self::RATING_SUFFICIENT => __( 'Ausreichend', 'open-data-wizard' ), + self::RATING_BAD => __( 'Mangelhaft', 'open-data-wizard' ), + )[ $rating ] ?? __( 'Unbekannt', 'open-data-wizard' ); + } + + // ------------------------------------------------------------------------- + // Abwärtskompatibilität (Legacy-API für Admin-Spalte, Validierung, Tests) + // ------------------------------------------------------------------------- + + /** + * Gibt die DCAT-AP-Indikatoren (Validierungsregistry) zurück. + * Weiterhin aus config/dcat-ap-fields.php geladen — steuert die Publish-Validierung. + * + * @return array + */ + public static function get_indicators(): array { + if ( class_exists( 'ODW_Fields' ) ) { + $defs = ODW_Fields::load_field_definitions(); + if ( ! empty( $defs ) ) { + return $defs; + } + } + + return array(); + } + + /** + * Summe der Pflichtpunkte aus der Indikator-Config (Legacy-Schwelle). + * + * @return int + */ + private static function get_required_only_score(): int { + $sum = 0; + foreach ( self::get_indicators() as $indicator ) { + if ( ! empty( $indicator['required'] ) ) { + $sum += (int) $indicator['points']; + } + } + + $threshold = $sum > 0 ? $sum : self::REQUIRED_ONLY_SCORE; + + return min( 100, $threshold ); + } + + /** + * Legacy: ermittelt ein 4-stufiges Level aus einem 0–100-Score. + * Weiterhin von der Admin-Listenspalte verwendet. * - * @param string $level One of LEVEL_HIGH, LEVEL_MEDIUM, LEVEL_LOW. + * @param int $score Numeric score 0–100. + * @return string One of LEVEL_PERFECT, LEVEL_HIGH, LEVEL_SUFFICIENT, LEVEL_LOW. + */ + public static function get_level( int $score ): string { + $required_only = self::get_required_only_score(); + + if ( 100 === $score ) { + return self::LEVEL_PERFECT; + } + if ( $score > $required_only ) { + return self::LEVEL_HIGH; + } + if ( $required_only === $score ) { + return self::LEVEL_SUFFICIENT; + } + return self::LEVEL_LOW; + } + + /** + * Legacy-Label für eine Level-Konstante (Admin-Spalte). + * + * @param string $level One of LEVEL_*. * @return string Translated label. */ public static function get_level_label( string $level ): string { @@ -527,16 +672,21 @@ public static function get_level_label( string $level ): string { // ------------------------------------------------------------------------- /** - * Returns a zeroed-out quality result used when no data has been stored yet. + * Leeres MQA-Ergebnis (noch keine Analyse vorhanden). * - * @return array{score: int, level: string, indicators: array, calculated_at: string} + * @return array */ private static function empty_result(): array { return array( + 'achieved' => 0, + 'assessable' => 0, + 'max' => self::MQA_MAX, + 'rating' => '', + 'dimensions' => array(), + 'metrics' => array(), + 'calculated_at' => '', 'score' => 0, 'level' => '', - 'indicators' => array(), - 'calculated_at' => '', ); } } diff --git a/languages/open-data-wizard-en_US.mo b/languages/open-data-wizard-en_US.mo index 3fde209009d544fc468b27f2b4b8f5623d833901..0dd6d7837ee211b107a9207f14a2ff9e2194084d 100644 GIT binary patch delta 10102 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 b+Y%aTU))pfload_class(); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $this->assertSame( ODW_Quality::RATING_EXCELLENT, ODW_Quality::get_rating( 230, 230 ) ); + $this->assertSame( ODW_Quality::RATING_EXCELLENT, ODW_Quality::get_rating( 405, 405 ) ); + } + + /** + * The proportional thresholds (351/221/121 of 405) map to the correct bands. + */ + public function test_get_rating_bands_on_full_scale(): void { + $this->load_class(); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $this->assertSame( ODW_Quality::RATING_EXCELLENT, ODW_Quality::get_rating( 351, 405 ) ); + $this->assertSame( ODW_Quality::RATING_GOOD, ODW_Quality::get_rating( 350, 405 ) ); + $this->assertSame( ODW_Quality::RATING_GOOD, ODW_Quality::get_rating( 221, 405 ) ); + $this->assertSame( ODW_Quality::RATING_SUFFICIENT, ODW_Quality::get_rating( 220, 405 ) ); + $this->assertSame( ODW_Quality::RATING_SUFFICIENT, ODW_Quality::get_rating( 121, 405 ) ); + $this->assertSame( ODW_Quality::RATING_BAD, ODW_Quality::get_rating( 120, 405 ) ); + $this->assertSame( ODW_Quality::RATING_BAD, ODW_Quality::get_rating( 0, 405 ) ); + } + + /** + * A zero assessable maximum yields the bad rating without division by zero. + */ + public function test_get_rating_zero_assessable_is_bad(): void { + $this->load_class(); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $this->assertSame( ODW_Quality::RATING_BAD, ODW_Quality::get_rating( 0, 0 ) ); + } + + /** + * Each MQA rating constant has a non-empty label; unknown falls back. + */ + public function test_get_rating_label(): void { + $this->load_class(); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $this->assertSame( 'Ausgezeichnet', ODW_Quality::get_rating_label( ODW_Quality::RATING_EXCELLENT ) ); + $this->assertSame( 'Mangelhaft', ODW_Quality::get_rating_label( ODW_Quality::RATING_BAD ) ); + $this->assertSame( 'Unbekannt', ODW_Quality::get_rating_label( 'nope' ) ); + } + + // ------------------------------------------------------------------------- + // get_metrics() — MQA-Konfiguration + // ------------------------------------------------------------------------- + + /** + * The MQA metric points sum to 405 across the five dimensions. + */ + public function test_get_metrics_sum_to_405(): void { + $this->load_class(); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $total = array_sum( array_column( ODW_Quality::get_metrics(), 'points' ) ); + $this->assertSame( 405, $total ); + } + + /** + * Each dimension reaches its documented MQA maximum. + */ + public function test_get_metrics_dimension_maxima(): void { + $this->load_class(); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); + + $by_dim = array(); + foreach ( ODW_Quality::get_metrics() as $m ) { + $by_dim[ $m['dimension'] ] = ( $by_dim[ $m['dimension'] ] ?? 0 ) + (int) $m['points']; + } + + $this->assertSame( 100, $by_dim['findability'] ); + $this->assertSame( 100, $by_dim['accessibility'] ); + $this->assertSame( 110, $by_dim['interoperability'] ); + $this->assertSame( 75, $by_dim['reusability'] ); + $this->assertSame( 20, $by_dim['contextuality'] ); + } + + // ------------------------------------------------------------------------- + // get() / store() — MQA-Persistierung + // ------------------------------------------------------------------------- + + /** + * Returns a zeroed result when no MQA data is stored in post meta. + */ + public function test_get_returns_empty_result_when_nothing_stored(): void { + $this->load_class(); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 42, '_odw_quality_level', true ) + ->with( 42, '_odw_mqa', true ) ->andReturn( '' ); $result = ODW_Quality::get( 42 ); $this->assertSame( 0, $result['score'] ); $this->assertSame( '', $result['level'] ); - $this->assertSame( array(), $result['indicators'] ); - $this->assertSame( '', $result['calculated_at'] ); + $this->assertSame( '', $result['rating'] ); } /** - * Returns score, level, and calculated_at from stored post meta. + * Returns the stored MQA structure verbatim. */ - public function test_get_returns_stored_values(): void { + public function test_get_returns_stored_mqa(): void { $this->load_class(); - \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 42, '_odw_quality_level', true ) - ->andReturn( 'high' ); - - \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 42, '_odw_quality_score', true ) - ->andReturn( '85' ); - - \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 42, '_odw_quality_indicators', true ) - ->andReturn( array( 'title' => array( 'passed' => true ) ) ); + $stored = array( + 'achieved' => 200, + 'assessable' => 230, + 'max' => 405, + 'rating' => 'good', + 'dimensions' => array(), + 'metrics' => array(), + 'calculated_at' => '2026-04-21 10:00:00', + 'score' => 87, + 'level' => 'high', + ); \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 42, '_odw_quality_calculated_at', true ) - ->andReturn( '2026-04-21 10:00:00' ); + ->with( 42, '_odw_mqa', true ) + ->andReturn( $stored ); $result = ODW_Quality::get( 42 ); - $this->assertSame( 85, $result['score'] ); - $this->assertSame( 'high', $result['level'] ); - $this->assertSame( '2026-04-21 10:00:00', $result['calculated_at'] ); + $this->assertSame( 'good', $result['rating'] ); + $this->assertSame( 200, $result['achieved'] ); + $this->assertSame( 87, $result['score'] ); } - // ------------------------------------------------------------------------- - // store() - // ------------------------------------------------------------------------- - /** - * Calls update_post_meta for all four quality meta keys. + * Persists the full MQA array plus the backward-compatible scalar keys. */ - public function test_store_calls_update_post_meta_for_all_keys(): void { + public function test_store_persists_mqa_and_bc_keys(): void { $this->load_class(); $result = array( - 'score' => 75, - 'level' => 'medium', - 'indicators' => array(), + 'rating' => 'good', 'calculated_at' => '2026-04-21 12:00:00', + 'score' => 75, + 'level' => 'high', ); - \WP_Mock::userFunction( 'update_post_meta' ) - ->with( 7, '_odw_quality_score', 75 ) - ->once(); - - \WP_Mock::userFunction( 'update_post_meta' ) - ->with( 7, '_odw_quality_level', 'medium' ) - ->once(); - - \WP_Mock::userFunction( 'update_post_meta' ) - ->with( 7, '_odw_quality_indicators', array() ) - ->once(); - - \WP_Mock::userFunction( 'update_post_meta' ) - ->with( 7, '_odw_quality_calculated_at', '2026-04-21 12:00:00' ) - ->once(); + \WP_Mock::userFunction( 'update_post_meta' )->with( 7, '_odw_mqa', $result )->once(); + \WP_Mock::userFunction( 'update_post_meta' )->with( 7, '_odw_quality_score', 75 )->once(); + \WP_Mock::userFunction( 'update_post_meta' )->with( 7, '_odw_quality_level', 'high' )->once(); + \WP_Mock::userFunction( 'update_post_meta' )->with( 7, '_odw_quality_calculated_at', '2026-04-21 12:00:00' )->once(); ODW_Quality::store( 7, $result ); - // WP_Mock ->once() expectations verified in tearDown; count them explicitly. $this->addToAssertionCount( 4 ); } @@ -281,44 +352,50 @@ public function test_store_calls_update_post_meta_for_all_keys(): void { // ------------------------------------------------------------------------- /** - * Adds odw:qualityScore to the dataset array when a quality level is stored. + * Adds odw:qualityScore (405-scale, rating, dimensions) when MQA data exists. */ - public function test_append_to_jsonld_adds_quality_data(): void { + public function test_append_to_jsonld_adds_mqa_data(): void { $this->load_class(); + \WP_Mock::userFunction( '__' )->andReturnArg( 0 ); \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 5, '_odw_quality_level', true ) - ->andReturn( 'high' ); - - \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 5, '_odw_quality_score', true ) - ->andReturn( '90' ); - - \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 5, '_odw_quality_indicators', true ) - ->andReturn( array() ); - - \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 5, '_odw_quality_calculated_at', true ) - ->andReturn( '2026-04-21 09:00:00' ); + ->with( 5, '_odw_mqa', true ) + ->andReturn( + array( + 'achieved' => 200, + 'assessable' => 230, + 'max' => 405, + 'rating' => 'good', + 'dimensions' => array( + 'findability' => array( + 'achieved' => 100, + 'assessable' => 100, + 'max' => 100, + ), + ), + 'metrics' => array(), + 'calculated_at' => '2026-04-21 09:00:00', + ) + ); $dataset = array( '@type' => 'dcat:Dataset' ); $result = ODW_Quality::append_to_jsonld( $dataset, 5 ); $this->assertArrayHasKey( 'odw:qualityScore', $result ); - $this->assertSame( 90, $result['odw:qualityScore']['odw:score'] ); - $this->assertSame( 'high', $result['odw:qualityScore']['odw:level'] ); - $this->assertSame( 100, $result['odw:qualityScore']['odw:maxScore'] ); + $this->assertSame( 200, $result['odw:qualityScore']['odw:score'] ); + $this->assertSame( 'good', $result['odw:qualityScore']['odw:rating'] ); + $this->assertSame( 405, $result['odw:qualityScore']['odw:maxScore'] ); + $this->assertArrayHasKey( 'findability', $result['odw:qualityScore']['odw:dimensions'] ); } /** - * Leaves the dataset array unchanged when no quality level is stored. + * Leaves the dataset array unchanged when no MQA data is stored. */ - public function test_append_to_jsonld_skips_when_no_level(): void { + public function test_append_to_jsonld_skips_when_no_data(): void { $this->load_class(); \WP_Mock::userFunction( 'get_post_meta' ) - ->with( 5, '_odw_quality_level', true ) + ->with( 5, '_odw_mqa', true ) ->andReturn( '' ); $dataset = array( '@type' => 'dcat:Dataset' );
' . esc_html__( 'Pflichtfelder', 'open-data-wizard' ) . '
' . esc_html( $section ) . '
' + . esc_html( $dim_labels[ $dim ] ?? $dim ) + . ' ' . esc_html( sprintf( '%d / %d', $d_ach, $d_ass ) ) + . ( $d_ass !== $d_max ? esc_html( sprintf( ' (max. %d)', $d_max ) ) : '' ) + . '
' . esc_html__( 'Optionale Angaben', 'open-data-wizard' ) . '