From 5288a1772662cf5fcc208dde1654b54a90dffa6e Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 14:52:58 +0000 Subject: [PATCH 1/6] fix(events): match listing duplicates by headliner and venue identity Multi-venue listing imports title shows 'Headliner with Support at Venue' and name venues loosely ('Iron Horse Music Hall' for 'The Iron Horse'). Add a headliner + venue-identity duplicate strategy and resolve address-less same-city venue name variants to the existing term, reporting ambiguity when more than one term matches. Closes #3965 --- .../EventDuplicateStrategy.php | 202 +++++++++++++ .../inc/Core/Venue_Taxonomy.php | 162 +++++++++- .../Unit/EventDuplicateHeadlinerVenueTest.php | 281 ++++++++++++++++++ .../Unit/VenueTaxonomyListingVariantTest.php | 162 ++++++++++ 4 files changed, 794 insertions(+), 13 deletions(-) create mode 100644 plugins/data-machine-events/tests/Unit/EventDuplicateHeadlinerVenueTest.php create mode 100644 plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php diff --git a/plugins/data-machine-events/inc/Core/DuplicateDetection/EventDuplicateStrategy.php b/plugins/data-machine-events/inc/Core/DuplicateDetection/EventDuplicateStrategy.php index d0bfbe6fdd..cff72e0b9b 100644 --- a/plugins/data-machine-events/inc/Core/DuplicateDetection/EventDuplicateStrategy.php +++ b/plugins/data-machine-events/inc/Core/DuplicateDetection/EventDuplicateStrategy.php @@ -193,6 +193,13 @@ public static function check( array $input ): ?array { if ( $match ) { return $match; } + + // Strategy 2b: same headliner at the same venue or a same-city + // venue-name variant (multi-venue listings, #3965). + $match = self::findByHeadlinerAndVenueIdentity( $title, $venue, $date_only, $startDate, $venue_term, $context ); + if ( $match ) { + return $match; + } } // Strategy 3: Exact title + date (with venue confirmation). @@ -544,6 +551,201 @@ public static function stripSupportClause( string $title ): string { return mb_strlen( $stripped ) < 3 ? $title : $stripped; } + // ----------------------------------------------------------------------- + // Strategy 2b: Headliner + date + venue identity (#3965) + // ----------------------------------------------------------------------- + + /** + * Subtitles that name one set of several, never a tour name. + */ + private const SET_SUBTITLE_PATTERN = '/\b(?:(?:early|late|first|second|1st|2nd|evening|afternoon)\s+(?:show|set|performance)|matinee|(?:night|show|set|day)\s*#?\d+)\b/i'; + + /** + * Match a same-day listing by headliner and venue identity. + * + * Multi-venue listing pages title a show "Headliner with Support at Venue" + * and name the venue loosely ("Iron Horse Music Hall" for "The Iron + * Horse"), while the venue's own feed adds tour names ("Hunxho - 2Night + * Only Tour", "Street Dogs: Wreck The Halls") or a different opener list. + * Neither the fuzzy title compare nor the venue-term-ID checks see those + * as one show (#3965). + * + * A candidate qualifies only when: + * - its headliner key (headlinerKey()) equals the incoming one exactly; + * - it sits at the incoming venue term, or at a term whose name is a + * Venue_Taxonomy::venue_names_are_variants() variant of the incoming + * venue name in the same city (positive city evidence, no state or + * country conflict). A different venue is never a match, so "Open Mic + * at X" and "Open Mic at Y" stay apart; + * - its start time is within the usual window (one hour for a generic, + * low-confidence headliner such as "Karaoke" or a one-word band), or it + * is the single cross-source match with no same-source candidate + * outside the window, mirroring Strategy 2. The cross-source fallback + * is not offered to a low-confidence headliner at a name-variant venue. + * More than one in-window candidate is ambiguous and never matched. + * + * @param string $title Incoming title. + * @param string $venue Incoming venue name. + * @param string $date_only Date in YYYY-MM-DD. + * @param string $startDate Incoming start datetime. + * @param \WP_Term|null $venue_term Resolved incoming venue term. + * @param array $context Incoming duplicate-check context. + * @return array|null Duplicate result or null. + */ + private static function findByHeadlinerAndVenueIdentity( string $title, string $venue, string $date_only, string $startDate, ?\WP_Term $venue_term, array $context ): ?array { + $incoming_names = array_values( array_unique( array_filter( array( trim( $venue ), $venue_term ? (string) $venue_term->name : '' ) ) ) ); + if ( empty( $incoming_names ) ) { + return null; + } + + $location = $context; + if ( $venue_term ) { + foreach ( array( 'city', 'state', 'country' ) as $field ) { + $meta_value = (string) get_term_meta( $venue_term->term_id, '_venue_' . $field, true ); + $location[ $field ] = '' !== $meta_value ? $meta_value : ( $context[ $field ] ?? '' ); + } + } + $incoming_state = (string) ( $location['state'] ?? '' ); + $source = (string) ( $context['source'] ?? '' ); + + $in_window = array(); + $cross_source = array(); + $same_source_outside = false; + foreach ( self::candidatePagesByDate( $date_only ) as $candidates ) { + foreach ( $candidates as $candidate ) { + $post_id = (int) $candidate->ID; + $terms = wp_get_post_terms( $post_id, 'venue' ); + if ( is_wp_error( $terms ) || empty( $terms ) ) { + continue; + } + + $same_term = false; + $variant = false; + foreach ( $terms as $term ) { + if ( $venue_term && (int) $term->term_id === (int) $venue_term->term_id ) { + $same_term = true; + break; + } + if ( ! $variant && self::isSameCityVenueVariant( $term, $incoming_names, $incoming_state, $location ) ) { + $variant = true; + } + } + if ( ! $same_term && ! $variant ) { + continue; + } + + $venue_names = array_merge( $incoming_names, wp_list_pluck( $terms, 'name' ) ); + $key = self::headlinerKey( $title, $venue_names ); + if ( '' === $key || self::headlinerKey( (string) $candidate->post_title, $venue_names ) !== $key ) { + continue; + } + + $low_confidence = EventIdentifierGenerator::isLowConfidenceTitle( $key ); + $candidate_dates = \DataMachineEvents\Core\EventDatesTable::get( $post_id ); + $existing_datetime = $candidate_dates ? $candidate_dates->start_datetime : ''; + $within = $low_confidence + ? self::isWithinStrictTimeWindow( $startDate, $existing_datetime, self::LOW_CONFIDENCE_TIME_WINDOW ) + : self::isWithinTimeWindow( $startDate, $existing_datetime ); + if ( $within ) { + $in_window[ $post_id ] = true; + } elseif ( $low_confidence && ! $same_term ) { + // A generic or one-word headliner at a name-variant venue + // needs the start time as positive evidence. + continue; + } elseif ( self::isCrossSourceCandidate( $post_id, $source ) ) { + $cross_source[ $post_id ] = true; + } else { + $same_source_outside = true; + } + } + } + + if ( 1 === count( $in_window ) ) { + return self::duplicateResult( (int) array_key_first( $in_window ), 'headliner_venue_identity' ); + } + if ( empty( $in_window ) && 1 === count( $cross_source ) && ! $same_source_outside ) { + return self::duplicateResult( (int) array_key_first( $cross_source ), 'headliner_venue_identity_cross_source' ); + } + + return null; + } + + /** + * Whether a candidate venue term is a same-city name variant of the incoming venue. + * + * @param \WP_Term $term Candidate venue term. + * @param string[] $incoming_names Incoming venue name(s). + * @param string $incoming_state Incoming venue state. + * @param array $location Incoming city/state/country. + * @return bool + */ + private static function isSameCityVenueVariant( \WP_Term $term, array $incoming_names, string $incoming_state, array $location ): bool { + $stored_state = (string) get_term_meta( $term->term_id, '_venue_state', true ); + $stored_city = (string) get_term_meta( $term->term_id, '_venue_city', true ); + $city = (string) ( $location['city'] ?? '' ); + foreach ( $incoming_names as $name ) { + if ( \DataMachineEvents\Core\Venue_Taxonomy::venue_names_are_variants( (string) $name, (string) $term->name, $incoming_state, $stored_state, $city, $stored_city ) ) { + return \DataMachineEvents\Core\Venue_Taxonomy::is_same_event_location( (int) $term->term_id, $location ); + } + } + + return false; + } + + /** + * Reduce an event title to its headliner for same-venue comparison (#3965). + * + * Removes, in order: a trailing "at " phrase for any of the given + * venue names (stripVenuePhrase()), explicit support markers + * (stripSupportClause()), a trailing parenthetical ("(2 Shows)"), a tour or + * subtitle after ":" / " - " unless it names a set ("Late Show"), a bare + * "with" support list (but not "An Evening with …"), and a co-bill list + * after ",", "&", "and", "+" or "/". A trailing "at " is also + * removed when is a Venue_Taxonomy::venue_names_are_variants() + * variant of a given name. Accents are folded and the result is normalized + * like computeTitleHash(). Only meaningful once the venue is confirmed. + * + * @param string $title Event title. + * @param string[] $venue_names Venue names whose "at " phrase may be stripped. + * @return string Headliner key, or '' when too short to trust. + */ + public static function headlinerKey( string $title, array $venue_names = array() ): string { + $text = remove_accents( html_entity_decode( $title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); + foreach ( $venue_names as $venue_name ) { + $text = self::stripVenuePhrase( $text, remove_accents( html_entity_decode( (string) $venue_name, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ) ); + } + // A trailing "at " whose venue is a listing-style variant of a + // known name ("at City Winery Boston" for "City Winery - Boston"). + $offset = 0; + while ( preg_match( '/\s+(?:at|@)\s+/iu', $text, $at, PREG_OFFSET_CAPTURE, $offset ) ) { + $tail = substr( $text, $at[0][1] + strlen( $at[0][0] ) ); + foreach ( $venue_names as $venue_name ) { + if ( '' !== trim( $tail ) && \DataMachineEvents\Core\Venue_Taxonomy::venue_names_are_variants( $tail, (string) $venue_name ) ) { + $text = substr( $text, 0, $at[0][1] ); + break 2; + } + } + $offset = $at[0][1] + strlen( $at[0][0] ); + } + $text = self::stripSupportClause( $text ); + $text = trim( (string) preg_replace( '/\s*\([^)]*\)\s*$/u', '', $text ) ); + + $parts = preg_split( '/\s*:\s+|\s+[-\x{2013}\x{2014}|]\s+/u', $text, 2 ); + if ( is_array( $parts ) && 2 === count( $parts ) && '' !== trim( $parts[0] ) && ! preg_match( self::SET_SUBTITLE_PATTERN, $parts[1] ) ) { + $text = $parts[0]; + } + + if ( ! preg_match( '/^(?:an?\s+)?(?:evening|night|afternoon|morning|conversation|celebration|tribute)\s+with\b/iu', $text ) ) { + $text = (string) preg_replace( '/\s+with\s+.*$/iu', '', $text ); + } + $text = (string) preg_replace( '/\s*(?:,|\s(?:&|\+|\/|and)\s).*$/iu', '', $text ); + + $key = EventIdentifierGenerator::normalizeBasic( $text ); + $key = trim( (string) preg_replace( '/\s+/', ' ', (string) preg_replace( '/[^a-z0-9\s]+/', '', $key ) ) ); + + return strlen( $key ) >= 3 ? $key : ''; + } + // ----------------------------------------------------------------------- // Strategy 3: Exact title + date with venue confirmation // ----------------------------------------------------------------------- diff --git a/plugins/data-machine-events/inc/Core/Venue_Taxonomy.php b/plugins/data-machine-events/inc/Core/Venue_Taxonomy.php index ded423c85b..f9c2751088 100644 --- a/plugins/data-machine-events/inc/Core/Venue_Taxonomy.php +++ b/plugins/data-machine-events/inc/Core/Venue_Taxonomy.php @@ -595,6 +595,12 @@ public static function get_venue_term_ids_by_tier( string $tier ): array { * Catches: "Saturn - Birmingham" = "Saturn Birmingham", * "Reggie's Rock Club" = "Reggies Rock Club", * "RADIO/EAST" = "Radio East" + * 4b. Core/variant name matching in the same city (#3965), run only when + * no exact, article-toggle, or normalized candidate exists. When the + * incoming side has no street address, listing-style name variants + * count: "Iron Horse Music Hall" = "The Iron Horse", "Paradise Rock + * Club" = "Paradise Rock Club presented by Citizens", "Rockwell" = + * "Rockwell - MA". Several same-city variants are ambiguous. * 5. Fuzzy name matching (edit distance ≤ 1–2, scaled for name length), * run only when no exact, article-toggle, or normalized candidate * exists, qualified by supplied geographic evidence (#921) @@ -1009,7 +1015,7 @@ public static function resolve_venue_identity( string $venue_name, array $venue_ 'term' => $term, 'term_id' => $term ? (int) $term->term_id : null, 'match_status' => $match_status, - 'matched_via' => $term ? 'name' : '', + 'matched_via' => $term ? (string) ( $name_match['matched_via'] ?? 'name' ) : '', 'conflicting_term_ids' => $name_match['conflicting_term_ids'], 'venue_name' => $venue_name, ); @@ -1121,7 +1127,7 @@ private static function find_venue_by_qualified_name( string $venue_name, array } if ( ! $had_candidates ) { - $core_match = self::resolve_core_name_tier( $venue_name, $venues, $venue_data ); + $core_match = self::resolve_core_name_tier( $venue_name, $venues, $venue_data, $normalized_by_term ); if ( $core_match['term'] || $core_match['ambiguous'] ) { return $core_match; } @@ -1155,12 +1161,22 @@ private static function find_venue_by_qualified_name( string $venue_name, array * evidence (same non-empty city or street). One compatible candidate is a * match; several are ambiguous. * - * @param string $venue_name Venue name as supplied. - * @param \WP_Term[] $venues All venue terms. - * @param array $venue_data Incoming venue metadata. - * @return array{term: \WP_Term|null, ambiguous: bool, conflict: bool, conflicting_term_ids: int[]} + * Address-less listings (#3965): multi-venue listing pages give only a + * venue name plus "City, ST", and their names are routinely variants of + * the venue's own name ("Iron Horse Music Hall" for "The Iron Horse", + * "Paradise Rock Club" for "Paradise Rock Club presented by Citizens"). + * When the incoming side carries no street address, venue_names_are_variants() + * also qualifies a candidate. The same positive-city requirement and the + * same ambiguity rule apply, so a variant never crosses cities and two + * same-city variants never get a guess. + * + * @param string $venue_name Venue name as supplied. + * @param \WP_Term[] $venues All venue terms. + * @param array $venue_data Incoming venue metadata. + * @param array $normalized_by_term Normalized venue names keyed by term ID. + * @return array{term: \WP_Term|null, ambiguous: bool, conflict: bool, conflicting_term_ids: int[], matched_via?: string} */ - private static function resolve_core_name_tier( string $venue_name, array $venues, array $venue_data ): array { + private static function resolve_core_name_tier( string $venue_name, array $venues, array $venue_data, array $normalized_by_term = array() ): array { $no_match = array( 'term' => null, 'ambiguous' => false, @@ -1168,16 +1184,47 @@ private static function resolve_core_name_tier( string $venue_name, array $venue 'conflicting_term_ids' => array(), ); - $incoming = self::core_venue_name( $venue_name, (string) ( $venue_data['state'] ?? '' ) ); + $incoming_state = (string) ( $venue_data['state'] ?? '' ); + $incoming = self::core_venue_name( $venue_name, $incoming_state ); if ( strlen( $incoming ) < self::FUZZY_NAME_MIN_LENGTH ) { return $no_match; } - $compatible = array(); + // Name variants are only trusted when no street address can speak for + // the venue; an addressed listing keeps the stricter rules above. + $incoming_address = self::is_unverified_field( $venue_data, 'address' ) ? '' : (string) ( $venue_data['address'] ?? '' ); + $allow_variants = ! self::address_has_street_component( $incoming_address ) + && '' !== trim( (string) ( $venue_data['city'] ?? '' ) ); + $incoming_tokens = $allow_variants ? array_filter( explode( ' ', self::normalize_venue_name_for_matching( $venue_name ) ) ) : array(); + + $compatible = array(); + $via_variant = array(); foreach ( $venues as $venue ) { - $state = (string) get_term_meta( (int) $venue->term_id, self::$meta_fields['state'], true ); - if ( self::core_venue_name( $venue->name, $state ) !== $incoming - && ! \DataMachineEvents\Utilities\EventIdentifierGenerator::hasLeadingVenueName( $venue_name, $venue->name ) ) { + $state = (string) get_term_meta( (int) $venue->term_id, self::$meta_fields['state'], true ); + $core_match = self::core_venue_name( $venue->name, $state ) === $incoming + || \DataMachineEvents\Utilities\EventIdentifierGenerator::hasLeadingVenueName( $venue_name, $venue->name ); + $is_variant = false; + if ( ! $core_match && $allow_variants ) { + // Cheap shared-word prefilter before the regex-heavy comparison: + // the venue taxonomy holds tens of thousands of terms. + $existing_normalized = $normalized_by_term[ (int) $venue->term_id ] ?? self::normalize_venue_name_for_matching( $venue->name ); + $shares_word = false; + foreach ( $incoming_tokens as $token ) { + if ( strlen( $token ) >= 3 && str_contains( ' ' . $existing_normalized . ' ', ' ' . $token . ' ' ) ) { + $shares_word = true; + break; + } + } + $is_variant = $shares_word && self::venue_names_are_variants( + $venue_name, + $venue->name, + $incoming_state, + $state, + (string) $venue_data['city'], + (string) get_term_meta( (int) $venue->term_id, self::$meta_fields['city'], true ) + ); + } + if ( ! $core_match && ! $is_variant ) { continue; } if ( self::has_geographic_conflict( (int) $venue->term_id, $venue_data ) ) { @@ -1185,14 +1232,18 @@ private static function resolve_core_name_tier( string $venue_name, array $venue } if ( self::has_positive_location_evidence( (int) $venue->term_id, $venue_data ) ) { $compatible[] = $venue; + if ( $is_variant ) { + $via_variant[ (int) $venue->term_id ] = true; + } } } if ( 1 === count( $compatible ) ) { + $variant = isset( $via_variant[ (int) $compatible[0]->term_id ] ); do_action( 'datamachine_log', 'info', - 'Venue matched via core name match', + $variant ? 'Venue matched via same-city name variant' : 'Venue matched via core name match', array( 'input_name' => $venue_name, 'matched_name' => $compatible[0]->name, @@ -1201,6 +1252,9 @@ private static function resolve_core_name_tier( string $venue_name, array $venue ); $no_match['term'] = $compatible[0]; + if ( $variant ) { + $no_match['matched_via'] = 'name_variant'; + } return $no_match; } @@ -1208,6 +1262,88 @@ private static function resolve_core_name_tier( string $venue_name, array $venue return $no_match; } + /** + * Listing-style venue identity key (#3965). + * + * Builds on core_venue_name() (accents, punctuation, apostrophes, a + * leading article, a matching "-XX" state suffix, a trailing "club") and + * additionally drops sponsor suffixes ("presented by Citizens"), a + * trailing parenthetical, a trailing "-" when it is the venue's own + * city ("Roadrunner-Boston"), a trailing "music hall", and a standalone + * "at" ("Crystal Ballroom at Somerville Theatre"). + * + * @param string $name Venue name. + * @param string $state Venue state (code or name); '' disables the state strip. + * @param string $city Venue city; '' disables the city-suffix strip. + * @return string Variant key, normalized for matching. + */ + public static function venue_variant_key( string $name, string $state = '', string $city = '' ): string { + $text = html_entity_decode( $name, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + $text = (string) preg_replace( '/\s+(?:presented|powered|sponsored|hosted)\s+by\s+.*$/iu', '', $text ); + $text = (string) preg_replace( '/\s*\([^)]*\)\s*$/u', '', $text ); + $city = trim( $city ); + if ( '' !== $city ) { + $text = (string) preg_replace( '/\s*[-\x{2013}\x{2014}]\s*' . preg_quote( $city, '/' ) . '$/iu', '', $text ); + } + $core = self::core_venue_name( $text, $state ); + $core = trim( (string) preg_replace( '/\s+/', ' ', (string) preg_replace( '/(?<=\s)at(?=\s)/', ' ', $core ) ) ); + $bare = trim( (string) preg_replace( '/\s+music\s+hall$/', '', $core ) ); + + return '' !== $bare ? $bare : $core; + } + + /** + * Whether two venue names are listing-style variants of one venue (#3965). + * + * True when the variant keys are equal, when the shorter key (at least two + * words) is the leading part of the longer one ("Paradise Rock Club" / + * "Paradise Rock Club Boston"), or when a comma-separated part of one name + * equals the other name ("John F. Swope Theater, Capitol Center for the + * Arts" / "Capitol Center for the Arts"). Single-word prefixes never count, + * so "The Basement" and "The Basement East" stay distinct. + * + * This is a name-only comparison: callers must also require the same city. + * + * @param string $name_a First venue name. + * @param string $name_b Second venue name. + * @param string $state_a State of the first venue ('' when unknown). + * @param string $state_b State of the second venue ('' when unknown). + * @param string $city_a City of the first venue ('' when unknown). + * @param string $city_b City of the second venue ('' when unknown). + * @return bool + */ + public static function venue_names_are_variants( string $name_a, string $name_b, string $state_a = '', string $state_b = '', string $city_a = '', string $city_b = '' ): bool { + $key_a = self::venue_variant_key( $name_a, $state_a, $city_a ); + $key_b = self::venue_variant_key( $name_b, $state_b, $city_b ); + if ( strlen( $key_a ) < self::FUZZY_NAME_MIN_LENGTH || strlen( $key_b ) < self::FUZZY_NAME_MIN_LENGTH ) { + return false; + } + if ( $key_a === $key_b ) { + return true; + } + + $shorter = strlen( $key_a ) <= strlen( $key_b ) ? $key_a : $key_b; + $longer = strlen( $key_a ) <= strlen( $key_b ) ? $key_b : $key_a; + if ( str_contains( $shorter, ' ' ) && str_starts_with( $longer, $shorter . ' ' ) ) { + return true; + } + + foreach ( array( array( $name_a, $state_a, $key_b, $city_a ), array( $name_b, $state_b, $key_a, $city_b ) ) as $pair ) { + $parts = explode( ',', html_entity_decode( $pair[0], ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); + if ( count( $parts ) < 2 ) { + continue; + } + foreach ( $parts as $part ) { + $part_key = self::venue_variant_key( $part, $pair[1], $pair[3] ); + if ( str_contains( $part_key, ' ' ) && $part_key === $pair[2] ) { + return true; + } + } + } + + return false; + } + /** * Normalized venue name without a source qualifier. * diff --git a/plugins/data-machine-events/tests/Unit/EventDuplicateHeadlinerVenueTest.php b/plugins/data-machine-events/tests/Unit/EventDuplicateHeadlinerVenueTest.php new file mode 100644 index 0000000000..2c21033439 --- /dev/null +++ b/plugins/data-machine-events/tests/Unit/EventDuplicateHeadlinerVenueTest.php @@ -0,0 +1,281 @@ +user->create( array( 'role' => 'administrator' ) ) ); + + $this->existing_terms = $this->venueTermIds(); + } + + public function tearDown(): void { + foreach ( array_diff( $this->venueTermIds(), $this->existing_terms ) as $term_id ) { + wp_delete_term( $term_id, 'venue' ); + } + wp_set_current_user( 0 ); + parent::tearDown(); + } + + // --------------------------------------------------------------------- + // Positives + // --------------------------------------------------------------------- + + /** + * Hunxho / Big Night Live: same venue term, tour suffix on the stored + * title, same day. Doors vs show times beyond the one-hour short-title + * window still match as the single cross-source candidate. + */ + public function test_same_venue_tour_suffix_same_day_is_duplicate(): void { + $venue = $this->venue( 'Big Night Live', 'Boston', 'MA' ); + $post_id = $this->event( 'Hunxho - 2Night Only Tour', '2031-12-04 18:00:00', $venue, 'ticketmaster' ); + + $same_time = $this->check( 'Hunxho', '2031-12-04 18:00', $venue, 'Big Night Live' ); + $this->assertSame( $post_id, (int) $same_time['match']['post_id'] ); + + $doors = $this->check( 'Hunxho', '2031-12-04 19:30', $venue, 'Big Night Live' ); + $this->assertIsArray( $doors ); + $this->assertSame( $post_id, (int) $doors['match']['post_id'] ); + $this->assertStringContainsString( 'headliner_venue_identity_cross_source', $doors['reason'] ); + } + + /** + * Whitechapel / Palladium: full support list plus " at Venue" against the + * venue feed's shorter opener list, 3.5 hours apart, different sources. + */ + public function test_support_list_and_venue_suffix_match_short_listing_cross_source(): void { + $venue = $this->venue( 'Palladium', 'Worcester', 'MA' ); + $post_id = $this->event( 'Whitechapel w/ Chelsea Grin', '2031-11-21 15:30:00', $venue, 'ticketmaster' ); + + $result = $this->check( + 'Whitechapel with The Acacia Strain, Chelsea Grin, Suicide Silence, Emmure, AngelMaker & Nailed Shut at Palladium', + '2031-11-21 19:00', + $venue, + 'Palladium' + ); + + $this->assertIsArray( $result ); + $this->assertSame( $post_id, (int) $result['match']['post_id'] ); + } + + /** + * Iron Horse: the listing venue resolved to a name-variant term in the + * same city ("Iron Horse Music Hall" vs "The Iron Horse"). + */ + public function test_name_variant_venue_in_same_city_is_duplicate(): void { + $canonical = $this->venue( 'The Iron Horse', 'Northampton', 'MA', '20 Center St' ); + $variant = $this->venue( 'Iron Horse Music Hall', 'Northampton', 'MA' ); + $post_id = $this->event( 'Fantastic Cat at The Iron Horse (2 Shows)', '2031-12-29 19:00:00', $canonical, 'universal_web_scraper' ); + + $result = $this->check( 'Fantastic Cat at Iron Horse Music Hall', '2031-12-29 19:00', $variant, 'Iron Horse Music Hall' ); + + $this->assertIsArray( $result ); + $this->assertSame( $post_id, (int) $result['match']['post_id'] ); + $this->assertStringContainsString( 'headliner_venue_identity', $result['reason'] ); + } + + /** + * Béla Fleck: diacritics, and a "Room, Venue" listing name against the + * venue's own term. + */ + public function test_accented_headliner_at_room_qualified_venue_is_duplicate(): void { + $canonical = $this->venue( 'Capitol Center for the Arts', 'Concord', 'NH', '44 South Main Street' ); + $room = $this->venue( 'John F. Swope Theater, Capitol Center for the Arts', 'Concord', 'NH' ); + $post_id = $this->event( 'Bela Fleck', '2031-12-05 20:00:00', $canonical, 'ticketmaster' ); + + $result = $this->check( 'Béla Fleck', '2031-12-05 20:00', $room, 'John F. Swope Theater, Capitol Center for the Arts' ); + + $this->assertIsArray( $result ); + $this->assertSame( $post_id, (int) $result['match']['post_id'] ); + } + + /** + * Street Dogs: "presented by" venue variant and a ":" tour subtitle. + */ + public function test_sponsor_variant_venue_and_subtitle_is_duplicate(): void { + $canonical = $this->venue( 'Paradise Rock Club presented by Citizens', 'Boston', 'MA', '967 Commonwealth Avenue' ); + $variant = $this->venue( 'Paradise Rock Club', 'Boston', 'MA' ); + $post_id = $this->event( 'Street Dogs: Wreck The Halls', '2031-12-18 18:30:00', $canonical, 'ticketmaster' ); + + $result = $this->check( 'Street Dogs with Gang Green & Liberty And Justice at Paradise Rock Club', '2031-12-18 18:30', $variant, 'Paradise Rock Club' ); + + $this->assertIsArray( $result ); + $this->assertSame( $post_id, (int) $result['match']['post_id'] ); + } + + // --------------------------------------------------------------------- + // Negatives + // --------------------------------------------------------------------- + + public function test_different_headliners_at_different_venues_same_day_are_distinct(): void { + $venue_a = $this->venue( 'Royale', 'Boston', 'MA' ); + $venue_b = $this->venue( 'Roadrunner', 'Boston', 'MA' ); + $this->event( 'Street Dogs at Royale', '2031-12-10 19:00:00', $venue_a, 'ticketmaster' ); + + $this->assertNull( $this->check( 'Bonobo at Roadrunner', '2031-12-10 19:00', $venue_b, 'Roadrunner' ) ); + } + + /** + * The same act listed at two genuinely different venues in one city on + * one night is not merged (source disagreement, not a duplicate). + */ + public function test_same_headliner_at_different_venues_same_city_is_distinct(): void { + $venue_a = $this->venue( 'Roadrunner', 'Boston', 'MA' ); + $venue_b = $this->venue( 'Royale', 'Boston', 'MA' ); + $this->event( 'Shakey Graves w/ Jobi Riccio', '2031-12-11 20:00:00', $venue_a, 'ticketmaster' ); + + $this->assertNull( $this->check( 'Shakey Graves at Royale', '2031-12-11 19:00', $venue_b, 'Royale' ) ); + } + + public function test_generic_title_at_two_venues_is_distinct(): void { + $venue_x = $this->venue( 'Lizard Lounge', 'Cambridge', 'MA' ); + $venue_y = $this->venue( 'Plough and Stars', 'Cambridge', 'MA' ); + $this->event( 'Open Mic at Lizard Lounge', '2031-11-28 20:00:00', $venue_x, 'universal_web_scraper' ); + + $this->assertNull( $this->check( 'Open Mic at Plough and Stars', '2031-11-28 20:00', $venue_y, 'Plough and Stars', 'ticketmaster' ) ); + } + + public function test_same_headliner_same_day_different_city_is_distinct(): void { + $northampton = $this->venue( 'The Iron Horse', 'Northampton', 'MA', '20 Center St' ); + $springfield = $this->venue( 'Iron Horse Music Hall', 'Springfield', 'MA' ); + $this->event( 'Fantastic Cat at The Iron Horse', '2031-12-30 19:00:00', $northampton, 'universal_web_scraper' ); + + $this->assertNull( $this->check( 'Fantastic Cat at Iron Horse Music Hall', '2031-12-30 19:00', $springfield, 'Iron Horse Music Hall' ) ); + } + + /** + * A one-word headliner at a name-variant venue needs the start time; + * the cross-source fallback is reserved for the same venue term. + */ + public function test_short_headliner_at_variant_venue_needs_start_time(): void { + $canonical = $this->venue( 'The Iron Horse', 'Northampton', 'MA', '20 Center St' ); + $variant = $this->venue( 'Iron Horse Music Hall', 'Northampton', 'MA' ); + $post_id = $this->event( 'LuxDeluxe at The Iron Horse', '2031-11-27 20:00:00', $canonical, 'ticketmaster' ); + + $this->assertNull( $this->check( 'Luxdeluxe at Iron Horse Music Hall', '2031-11-27 22:30', $variant, 'Iron Horse Music Hall' ) ); + + $result = $this->check( 'Luxdeluxe at Iron Horse Music Hall', '2031-11-27 20:00', $variant, 'Iron Horse Music Hall' ); + $this->assertSame( $post_id, (int) $result['match']['post_id'] ); + } + + // --------------------------------------------------------------------- + // headlinerKey() + // --------------------------------------------------------------------- + + public function test_headliner_key_reduces_listing_titles_to_the_headliner(): void { + $this->assertSame( 'hunxho', EventDuplicateStrategy::headlinerKey( 'Hunxho - 2Night Only Tour' ) ); + $this->assertSame( 'street dogs', EventDuplicateStrategy::headlinerKey( 'Street Dogs: Wreck The Halls' ) ); + $this->assertSame( 'street dogs', EventDuplicateStrategy::headlinerKey( 'Street Dogs with Gang Green & Liberty And Justice at Paradise Rock Club', array( 'Paradise Rock Club' ) ) ); + $this->assertSame( 'fantastic cat', EventDuplicateStrategy::headlinerKey( 'Fantastic Cat at The Iron Horse (2 Shows)', array( 'The Iron Horse' ) ) ); + $this->assertSame( 'bela fleck', EventDuplicateStrategy::headlinerKey( 'Béla Fleck' ) ); + $this->assertSame( 'ronnie earl', EventDuplicateStrategy::headlinerKey( 'Ronnie Earl and The Broadcasters' ) ); + $this->assertSame( 'john nemeth', EventDuplicateStrategy::headlinerKey( "John Németh at Jimmy's Jazz & Blues Club", array( "Jimmy's Jazz and Blues Club" ) ) ); + } + + public function test_headliner_key_keeps_set_subtitles_and_evening_with(): void { + $this->assertSame( 'cyrille aimee late show', EventDuplicateStrategy::headlinerKey( 'Cyrille Aimee - Late Show' ) ); + $this->assertSame( 'evening with bob', EventDuplicateStrategy::headlinerKey( 'An Evening with Bob' ) ); + $this->assertSame( 'night at the museum', EventDuplicateStrategy::headlinerKey( 'Night at the Museum', array( 'Museum Bar' ) ) ); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + /** @return int[] */ + private function venueTermIds(): array { + return array_map( + 'intval', + (array) get_terms( + array( + 'taxonomy' => 'venue', + 'hide_empty' => false, + 'fields' => 'ids', + ) + ) + ); + } + + private function venue( string $name, string $city, string $state, string $address = '' ): int { + $existing = get_term_by( 'name', $name, 'venue' ); + $args = $existing ? array( 'slug' => sanitize_title( $name . '-' . $city . '-' . uniqid() ) ) : array(); + $term = wp_insert_term( $name, 'venue', $args ); + $this->assertNotWPError( $term ); + $term_id = (int) $term['term_id']; + update_term_meta( $term_id, '_venue_city', $city ); + update_term_meta( $term_id, '_venue_state', $state ); + update_term_meta( $term_id, '_venue_country', 'US' ); + if ( '' !== $address ) { + update_term_meta( $term_id, '_venue_address', $address ); + } + + return $term_id; + } + + private function event( string $title, string $start, int $venue_id, string $source ): int { + $post_id = wp_insert_post( + array( + 'post_title' => $title, + 'post_type' => 'data_machine_events', + 'post_status' => 'publish', + ) + ); + $this->assertGreaterThan( 0, $post_id ); + wp_set_object_terms( $post_id, array( $venue_id ), 'venue' ); + EventDatesTable::upsert( $post_id, $start ); + update_post_meta( $post_id, '_datamachine_event_source', $source ); + + return (int) $post_id; + } + + private function check( string $title, string $start, int $venue_id, string $venue_name, string $source = 'universal_web_scraper' ): ?array { + return EventDuplicateStrategy::check( + array( + 'title' => $title, + 'context' => array( + 'venue' => $venue_name, + 'startDate' => $start, + 'ticketUrl' => '', + 'city' => (string) get_term_meta( $venue_id, '_venue_city', true ), + 'state' => (string) get_term_meta( $venue_id, '_venue_state', true ), + 'venue_term_id' => $venue_id, + 'source' => $source, + ), + ) + ); + } +} diff --git a/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php b/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php new file mode 100644 index 0000000000..0d15bfd71a --- /dev/null +++ b/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php @@ -0,0 +1,162 @@ +existing = $this->termIds(); + } + + public function tearDown(): void { + foreach ( array_diff( $this->termIds(), $this->existing ) as $term_id ) { + wp_delete_term( $term_id, 'venue' ); + } + parent::tearDown(); + } + + public function test_music_hall_variant_resolves_to_existing_same_city_venue(): void { + $term_id = $this->venue( 'The Iron Horse', 'Northampton', 'MA', '20 Center St' ); + + $result = Venue_Taxonomy::find_or_create_venue( 'Iron Horse Music Hall', array( 'city' => 'Northampton', 'state' => 'MA', 'country' => 'US' ) ); + + $this->assertSame( $term_id, (int) $result['term_id'] ); + $this->assertFalse( $result['was_created'] ); + $this->assertSame( 'matched', $result['match_status'] ); + $this->assertSame( 'name_variant', $result['matched_via'] ); + $this->assertFalse( get_term_by( 'name', 'Iron Horse Music Hall', 'venue' ) ); + } + + /** + * @dataProvider listing_variant_provider + */ + public function test_listing_variants_resolve_to_existing_venue( string $stored, string $incoming, string $city, string $state ): void { + $term_id = $this->venue( $stored, $city, $state ); + + $identity = Venue_Taxonomy::resolve_venue_identity( $incoming, array( 'city' => $city, 'state' => $state ) ); + + $this->assertSame( 'matched', $identity['match_status'], "{$incoming} should resolve to {$stored}." ); + $this->assertSame( $term_id, (int) $identity['term_id'] ); + } + + public function listing_variant_provider(): array { + return array( + 'sponsor suffix' => array( 'Paradise Rock Club presented by Citizens', 'Paradise Rock Club', 'Boston', 'MA' ), + 'state suffix' => array( 'Rockwell - MA', 'Rockwell', 'Somerville', 'MA' ), + 'city suffix' => array( 'Roadrunner-Boston', 'Roadrunner', 'Boston', 'MA' ), + 'room, venue' => array( 'Capitol Center for the Arts', 'John F. Swope Theater, Capitol Center for the Arts', 'Concord', 'NH' ), + 'parenthetical city' => array( 'The Grand (Boston)', 'The Grand', 'Boston', 'MA' ), + 'room at vs comma' => array( 'Crystal Ballroom at Somerville Theatre', 'Crystal Ballroom, Somerville Theatre', 'Somerville', 'MA' ), + 'music hall + accent' => array( 'Café Nine', 'Cafe Nine Music Hall', 'New Haven', 'CT' ), + ); + } + + public function test_two_same_city_variants_are_ambiguous(): void { + $this->venue( 'Paradise Rock Club presented by Citizens', 'Boston', 'MA' ); + $this->venue( 'Paradise Rock Club (Boston)', 'Boston', 'MA' ); + $before = count( $this->termIds() ); + + $result = Venue_Taxonomy::find_or_create_venue( 'Paradise Rock Club', array( 'city' => 'Boston', 'state' => 'MA' ) ); + + $this->assertSame( 'ambiguous', $result['match_status'] ); + $this->assertNull( $result['term_id'] ); + $this->assertFalse( $result['was_created'] ); + $this->assertCount( $before, $this->termIds() ); + } + + public function test_variant_in_other_city_is_not_matched(): void { + $term_id = $this->venue( 'The Iron Horse', 'Northampton', 'MA' ); + + $result = Venue_Taxonomy::find_or_create_venue( 'Iron Horse Music Hall', array( 'city' => 'Springfield', 'state' => 'MA' ) ); + + $this->assertNotSame( $term_id, (int) $result['term_id'] ); + $this->assertTrue( $result['was_created'] ); + } + + public function test_variant_requires_incoming_city(): void { + $term_id = $this->venue( 'The Iron Horse', 'Northampton', 'MA' ); + + $identity = Venue_Taxonomy::resolve_venue_identity( 'Iron Horse Music Hall', array() ); + + $this->assertNotSame( $term_id, (int) $identity['term_id'] ); + } + + /** + * An incoming street address keeps the stricter address/name rules: + * variants are only for address-less listings. + */ + public function test_variant_not_used_when_incoming_has_street_address(): void { + $term_id = $this->venue( 'The Iron Horse', 'Northampton', 'MA', '20 Center St' ); + + $identity = Venue_Taxonomy::resolve_venue_identity( 'Iron Horse Music Hall', array( 'address' => '99 King St', 'city' => 'Northampton', 'state' => 'MA' ) ); + + $this->assertNotSame( $term_id, (int) $identity['term_id'] ); + } + + public function test_single_word_prefix_is_not_a_variant(): void { + $term_id = $this->venue( 'The Basement East', 'Nashville', 'TN' ); + + $identity = Venue_Taxonomy::resolve_venue_identity( 'The Basement', array( 'city' => 'Nashville', 'state' => 'TN' ) ); + + $this->assertNotSame( $term_id, (int) $identity['term_id'] ); + $this->assertFalse( Venue_Taxonomy::venue_names_are_variants( 'The Basement', 'The Basement East' ) ); + } + + public function test_venue_names_are_variants_rules(): void { + $this->assertTrue( Venue_Taxonomy::venue_names_are_variants( 'Iron Horse Music Hall', 'The Iron Horse' ) ); + $this->assertTrue( Venue_Taxonomy::venue_names_are_variants( "Jimmy's Jazz & Blues Club", 'Jimmys Jazz and Blues Club' ) ); + $this->assertTrue( Venue_Taxonomy::venue_names_are_variants( 'Rockwell', 'Rockwell - MA', 'MA', 'MA' ) ); + $this->assertFalse( Venue_Taxonomy::venue_names_are_variants( 'Rockwell', 'Rockwell - MA', '', 'CA' ) ); + $this->assertFalse( Venue_Taxonomy::venue_names_are_variants( 'Middle East - Upstairs', 'Middle East - Downstairs' ) ); + $this->assertFalse( Venue_Taxonomy::venue_names_are_variants( 'Royale', 'Roadrunner' ) ); + $this->assertFalse( Venue_Taxonomy::venue_names_are_variants( 'Upstairs at the Palladium', 'Palladium' ) ); + } + + /** @return int[] */ + private function termIds(): array { + return array_map( + 'intval', + (array) get_terms( + array( + 'taxonomy' => 'venue', + 'hide_empty' => false, + 'fields' => 'ids', + ) + ) + ); + } + + private function venue( string $name, string $city, string $state, string $address = '' ): int { + $result = wp_insert_term( $name, 'venue' ); + $this->assertNotWPError( $result ); + $term_id = (int) $result['term_id']; + update_term_meta( $term_id, Venue_Taxonomy::$meta_fields['city'], $city ); + update_term_meta( $term_id, Venue_Taxonomy::$meta_fields['state'], $state ); + if ( '' !== $address ) { + update_term_meta( $term_id, Venue_Taxonomy::$meta_fields['address'], $address ); + } + + return $term_id; + } +} From af7dbafd8f873e7f5be2d2bc3a51bef7a6a8c5e3 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 15:15:30 +0000 Subject: [PATCH 2/6] fix(events): phpstan shape for matched_via, substring venue prefilter --- .../data-machine-events/inc/Core/Venue_Taxonomy.php | 7 ++++--- .../tests/Unit/VenueTaxonomyListingVariantTest.php | 13 ++++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/plugins/data-machine-events/inc/Core/Venue_Taxonomy.php b/plugins/data-machine-events/inc/Core/Venue_Taxonomy.php index f9c2751088..0a6a74a3a7 100644 --- a/plugins/data-machine-events/inc/Core/Venue_Taxonomy.php +++ b/plugins/data-machine-events/inc/Core/Venue_Taxonomy.php @@ -1038,7 +1038,7 @@ public static function resolve_venue_identity( string $venue_name, array $venue_ * * @param string $venue_name Venue name to resolve. * @param array $venue_data Incoming venue metadata. - * @return array{term: \WP_Term|null, ambiguous: bool, conflict: bool, conflicting_term_ids: int[]} + * @return array{term: \WP_Term|null, ambiguous: bool, conflict: bool, conflicting_term_ids: int[], matched_via?: string} */ private static function find_venue_by_qualified_name( string $venue_name, array $venue_data ): array { $no_match = array( @@ -1206,11 +1206,12 @@ private static function resolve_core_name_tier( string $venue_name, array $venue $is_variant = false; if ( ! $core_match && $allow_variants ) { // Cheap shared-word prefilter before the regex-heavy comparison: - // the venue taxonomy holds tens of thousands of terms. + // the venue taxonomy holds tens of thousands of terms. Substring, + // not whole-word, so glued names ("roadrunnerboston") pass. $existing_normalized = $normalized_by_term[ (int) $venue->term_id ] ?? self::normalize_venue_name_for_matching( $venue->name ); $shares_word = false; foreach ( $incoming_tokens as $token ) { - if ( strlen( $token ) >= 3 && str_contains( ' ' . $existing_normalized . ' ', ' ' . $token . ' ' ) ) { + if ( strlen( $token ) >= 4 && str_contains( $existing_normalized, $token ) ) { $shares_word = true; break; } diff --git a/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php b/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php index 0d15bfd71a..d924b6b155 100644 --- a/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php +++ b/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php @@ -56,7 +56,18 @@ public function test_listing_variants_resolve_to_existing_venue( string $stored, $identity = Venue_Taxonomy::resolve_venue_identity( $incoming, array( 'city' => $city, 'state' => $state ) ); - $this->assertSame( 'matched', $identity['match_status'], "{$incoming} should resolve to {$stored}." ); + $stored_term = get_term( $term_id, 'venue' ); + $diagnostic = wp_json_encode( + array( + 'identity' => array( $identity['match_status'], $identity['term_id'], $identity['matched_via'] ), + 'stored_name' => $stored_term ? $stored_term->name : null, + 'stored_key' => Venue_Taxonomy::venue_variant_key( $stored_term ? $stored_term->name : $stored, $state, $city ), + 'incoming' => Venue_Taxonomy::venue_variant_key( $incoming, $state, $city ), + 'variants' => Venue_Taxonomy::venue_names_are_variants( $incoming, $stored_term ? $stored_term->name : $stored, $state, $state, $city, $city ), + 'term_count' => count( $this->termIds() ), + ) + ); + $this->assertSame( 'matched', $identity['match_status'], "{$incoming} should resolve to {$stored}: {$diagnostic}" ); $this->assertSame( $term_id, (int) $identity['term_id'] ); } From 76f6ec4ccb2ca2a0be8e3d2696da6c17f511a5e4 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 15:24:06 +0000 Subject: [PATCH 3/6] test(events): avoid accented venue fixture in SQLite test DB --- .../tests/Unit/VenueTaxonomyListingVariantTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php b/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php index d924b6b155..cff81ea2f9 100644 --- a/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php +++ b/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php @@ -79,7 +79,9 @@ public function listing_variant_provider(): array { 'room, venue' => array( 'Capitol Center for the Arts', 'John F. Swope Theater, Capitol Center for the Arts', 'Concord', 'NH' ), 'parenthetical city' => array( 'The Grand (Boston)', 'The Grand', 'Boston', 'MA' ), 'room at vs comma' => array( 'Crystal Ballroom at Somerville Theatre', 'Crystal Ballroom, Somerville Theatre', 'Somerville', 'MA' ), - 'music hall + accent' => array( 'Café Nine', 'Cafe Nine Music Hall', 'New Haven', 'CT' ), + // Accent folding is covered by headlinerKey tests: the WP Codebox + // SQLite test database returns no terms once an accented name exists. + 'music hall suffix' => array( 'Cafe Nine', 'Cafe Nine Music Hall', 'New Haven', 'CT' ), ); } From 6ea83c1622c07f6c3496cb66ad8e0db89f5336bd Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 15:35:13 +0000 Subject: [PATCH 4/6] fix(events): let several same-city venue variants fall through to creation instead of stranding the event --- .../inc/Core/Venue_Taxonomy.php | 26 +++++++++++++++++++ .../Unit/VenueTaxonomyListingVariantTest.php | 17 ++++++------ 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/plugins/data-machine-events/inc/Core/Venue_Taxonomy.php b/plugins/data-machine-events/inc/Core/Venue_Taxonomy.php index 0a6a74a3a7..945fe02779 100644 --- a/plugins/data-machine-events/inc/Core/Venue_Taxonomy.php +++ b/plugins/data-machine-events/inc/Core/Venue_Taxonomy.php @@ -1239,6 +1239,32 @@ private static function resolve_core_name_tier( string $venue_name, array $venue } } + // Variants are advisory: core-name candidates keep their original + // semantics, and a variant only resolves when it is the single candidate. + // Several same-city variants (rooms of one venue, e.g. "Middle East - + // Upstairs" and "- Downstairs") fall through to term creation as before + // rather than reporting ambiguity, which would leave the event venue-less. + $core_compatible = array_values( + array_filter( + $compatible, + static fn( $venue ) => ! isset( $via_variant[ (int) $venue->term_id ] ) + ) + ); + if ( $core_compatible ) { + $compatible = $core_compatible; + } elseif ( count( $compatible ) > 1 ) { + do_action( + 'datamachine_log', + 'info', + 'Venue name variants matched several same-city venues; not guessing', + array( + 'input_name' => $venue_name, + 'candidates' => array_map( static fn( $venue ) => (int) $venue->term_id, $compatible ), + ) + ); + return $no_match; + } + if ( 1 === count( $compatible ) ) { $variant = isset( $via_variant[ (int) $compatible[0]->term_id ] ); do_action( diff --git a/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php b/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php index cff81ea2f9..9d4756298d 100644 --- a/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php +++ b/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php @@ -85,17 +85,16 @@ public function listing_variant_provider(): array { ); } - public function test_two_same_city_variants_are_ambiguous(): void { - $this->venue( 'Paradise Rock Club presented by Citizens', 'Boston', 'MA' ); - $this->venue( 'Paradise Rock Club (Boston)', 'Boston', 'MA' ); - $before = count( $this->termIds() ); + public function test_several_same_city_variants_do_not_guess_or_strand_the_event(): void { + $upstairs = $this->venue( 'Middle East - Upstairs', 'Cambridge', 'MA' ); + $downstairs = $this->venue( 'Middle East - Downstairs', 'Cambridge', 'MA' ); - $result = Venue_Taxonomy::find_or_create_venue( 'Paradise Rock Club', array( 'city' => 'Boston', 'state' => 'MA' ) ); + $result = Venue_Taxonomy::find_or_create_venue( 'Middle East', array( 'city' => 'Cambridge', 'state' => 'MA' ) ); - $this->assertSame( 'ambiguous', $result['match_status'] ); - $this->assertNull( $result['term_id'] ); - $this->assertFalse( $result['was_created'] ); - $this->assertCount( $before, $this->termIds() ); + // Neither room is guessed, and the event still gets a venue term. + $this->assertNotSame( 'ambiguous', $result['match_status'] ); + $this->assertNotNull( $result['term_id'] ); + $this->assertNotContains( (int) $result['term_id'], array( $upstairs, $downstairs ) ); } public function test_variant_in_other_city_is_not_matched(): void { From d6103d21cf36b220c7d2b7cca2589af13ed9d08b Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 15:35:28 +0000 Subject: [PATCH 5/6] test(events): use variant-only fixture for the several-variants case --- .../tests/Unit/VenueTaxonomyListingVariantTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php b/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php index 9d4756298d..97fe690628 100644 --- a/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php +++ b/plugins/data-machine-events/tests/Unit/VenueTaxonomyListingVariantTest.php @@ -86,10 +86,10 @@ public function listing_variant_provider(): array { } public function test_several_same_city_variants_do_not_guess_or_strand_the_event(): void { - $upstairs = $this->venue( 'Middle East - Upstairs', 'Cambridge', 'MA' ); - $downstairs = $this->venue( 'Middle East - Downstairs', 'Cambridge', 'MA' ); + $upstairs = $this->venue( 'Paradise Rock Club presented by Citizens', 'Boston', 'MA' ); + $downstairs = $this->venue( 'Paradise Rock Club (Boston)', 'Boston', 'MA' ); - $result = Venue_Taxonomy::find_or_create_venue( 'Middle East', array( 'city' => 'Cambridge', 'state' => 'MA' ) ); + $result = Venue_Taxonomy::find_or_create_venue( 'Paradise Rock Club', array( 'city' => 'Boston', 'state' => 'MA' ) ); // Neither room is guessed, and the event still gets a venue term. $this->assertNotSame( 'ambiguous', $result['match_status'] ); From cabf95cf84577d0c86e6304c76840db4cb6f31d2 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 16:00:50 +0000 Subject: [PATCH 6/6] style(events): align array arrows --- plugins/data-machine-events/inc/Core/Venue_Taxonomy.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/data-machine-events/inc/Core/Venue_Taxonomy.php b/plugins/data-machine-events/inc/Core/Venue_Taxonomy.php index 945fe02779..861b857f5c 100644 --- a/plugins/data-machine-events/inc/Core/Venue_Taxonomy.php +++ b/plugins/data-machine-events/inc/Core/Venue_Taxonomy.php @@ -1258,8 +1258,8 @@ private static function resolve_core_name_tier( string $venue_name, array $venue 'info', 'Venue name variants matched several same-city venues; not guessing', array( - 'input_name' => $venue_name, - 'candidates' => array_map( static fn( $venue ) => (int) $venue->term_id, $compatible ), + 'input_name' => $venue_name, + 'candidates' => array_map( static fn( $venue ) => (int) $venue->term_id, $compatible ), ) ); return $no_match;