'false', 'startat' => '2:03', 'idle-time-limit' => '2', 'fontSize' => '15px', 'fontfamily' => "'Fira Code', monospace", 'lineheight' => 1.4, 'logger' => 'console', 'unknown' => 'bad', 0 => 'bare', 'rows' => array( 2 ) ) ); same( false, isset( $attrs['unknown'] ), 'Unknown attributes are discarded.' ); same( false, isset( $attrs['logger'] ), 'No executable logger objects.' ); same( false, isset( $attrs['rows'] ), 'Nested option values are discarded.' ); $options = player_options( $attrs ); same( false, $options['autoPlay'], 'Lowercase autoplay normalizes and respects false.' ); same( 123.0, $options['startAt'], 'Lowercase startAt normalizes.' ); same( 2.0, $options['idleTimeLimit'], 'Hyphenated idleTimeLimit normalizes.' ); same( '15px', $options['terminalFontSize'], 'Legacy font aliases map to current player options.' ); same( "'Fira Code', monospace", $options['terminalFontFamily'], 'Font lists survive safely.' ); same( 1.4, $options['terminalLineHeight'], 'Legacy lineHeight maps to current name.' ); $options = player_options( normalize_attributes( array( 'controls' => 'false', 'preload' => 'false', 'loop' => 'false', 'fit' => 'false' ) ) ); same( false, $options['controls'], 'controls=false is a boolean, never a truthy string.' ); same( false, $options['preload'], 'preload=false is retained.' ); same( false, $options['loop'], 'loop=false is retained.' ); same( 'none', $options['fit'], 'Legacy fit=false maps to none.' ); foreach ( array( 'always' => true, 'never' => false, 'auto' => 'auto' ) as $input => $expected ) { same( $expected, player_options( array( 'controls' => $input ) )['controls'], 'Block control enum converts to player API.' ); } same( 3, player_options( array( 'loop' => '3' ) )['loop'], 'Finite replay counts stay supported in shortcodes.' ); $options = player_options( array( 'speed' => -1, 'idleTimeLimit' => 0, 'cols' => 4.5, 'rows' => 1001, 'startAt' => -1, 'terminalFontSize' => '12px; color:red', 'terminalFontFamily' => 'url(https://bad.test)', 'theme' => 'x" onclick=bad', 'poster' => 'javascript:alert(1)' ) ); same( array( 'autoPlay' => false, 'preload' => true, 'fit' => 'width' ), $options, 'Invalid options omitted, safe defaults retained.' ); same( 'auto/dracula', player_options( array( 'theme' => 'auto/dracula' ) )['theme'], 'Player theme fallback syntax retained.' ); same( 'npt:1:23', player_options( array( 'poster' => 'npt:1:23' ) )['poster'], 'Safe NPT poster supported.' ); same( 'data:text/plain,', player_options( array( 'poster' => 'data:text/plain,' ) )['poster'], 'Plain terminal-text posters are supported.' ); reset_enqueues(); same( '', render_shortcode( array() ), 'Unclosed/empty shortcode returns no player.' ); same( '', render_player( array( 'source' => 'javascript:alert(1)' ) ), 'Invalid source emits no HTML.' ); same( array(), $GLOBALS['ap_test']['enqueued_scripts'], 'Invalid source does not enqueue assets.' ); $html = render_shortcode( array( 'controls' => 'false', 'autoPlay' => 'true', 'startAt' => '12', 'idleTimeLimit' => '2' ), 'https://example.test/demo.cast?a=1&b=2' ); contains( 'class="asciiplayer-container"', $html, 'Render adapter class.' ); contains( 'data-ap-src="https://example.test/demo.cast?a=1&b=2"', $html, 'URL is escaped for an HTML attribute.' ); same( false, options_from( $html )['controls'], 'Markup has typed JSON false.' ); same( true, options_from( $html )['autoPlay'], 'CamelCase boolean handled.' ); same( 12, options_from( $html )['startAt'], 'JSON numeric startAt.' ); same( 2, options_from( $html )['idleTimeLimit'], 'JSON numeric idleTimeLimit.' ); same( true, isset( $GLOBALS['ap_test']['enqueued_scripts']['lamgc-asciiplayer-view'] ), 'Every valid render requests the frontend adapter.' ); same( true, isset( $GLOBALS['ap_test']['enqueued_styles']['lamgc-asciiplayer-style'] ), 'Every valid render requests CSS.' ); $html = render_block( array( 'source' => '/demo.cast', 'poster' => 'data:text/plain,">', 'legacyStyle' => 'width:80%;color:red;background:url(javascript:alert(1))' ) ); same( false, false !== strpos( $html, '