Basic release

This commit is contained in:
2023-12-30 14:28:21 +01:00
parent 4869f1ef2f
commit bf2892ab29
125 changed files with 10729 additions and 0 deletions

View File

@ -0,0 +1,7 @@
<phpunit boostrap="tests/bootstrap.php">
<testsuites>
<testsuite>
<directory suffix="Test.php" phpVersion="5.4.0">tests</directory>
</testsuite>
</testsuites>
</phpunit>

View File

@ -0,0 +1,98 @@
<?php
namespace Shy\WordPress;
/**
* A composite option with a fixed number of suboptions and their default values.
*/
abstract class CompositeOption implements \ArrayAccess, \Countable, \IteratorAggregate
{
use HookableTrait;
/**
* @var string
*/
protected $slug;
/**
* @return string
*/
public function getSlug()
{
return $this->slug;
}
protected function __construct( $slug )
{
$this->slug = (string) $slug;
$this->addHookMethod( 'default_option_' . $this->slug, 'getDefaults' );
}
/**
* Return default values for all suboptions.
* Hooked into get_option() defaults.
*
* @return array
*/
abstract public function getDefaults();
/**
* @param string $offset
* @return mixed
*/
public function getDefault( $offset )
{
return $this->getDefaults()[ $offset ];
}
public function offsetExists( $offset )
{
$settings = get_option( $this->slug );
return isset( $settings[ $offset ] );
}
public function offsetGet( $offset )
{
$settings = get_option( $this->slug );
if ( ! isset( $settings[ $offset ] ) ) {
throw new \OutOfBoundsException( "There is no setting '$offset'." );
}
return $settings[ $offset ];
}
public function offsetSet( $offset, $value )
{
$settings = get_option( $this->slug );
if ( ! isset( $settings[ $offset ] ) ) {
throw new \OutOfBoundsException( "There is no setting '$offset'." );
}
$settings[ $offset ] = $value;
update_option( $this->slug, $settings );
}
public function offsetUnset( $offset )
{
throw new \BadMethodCallException( 'You cannot unset settings.' );
}
public function count()
{
return count( $this->getDefaults() );
}
public function getIterator()
{
return new \ArrayIterator( get_option( $this->slug ) );
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace Shy\WordPress;
/**
* Making actions and filters how they should be.
*
* Default to pass all arguments.
*/
trait HookableTrait
{
/**
* @param string $action_or_filter
* @param string $method
* @param int $priority
* @param int $acceptedArgs
*/
protected function addHookMethod( $action_or_filter, $method, $priority = 10, $acceptedArgs = 99 )
{
add_filter( $action_or_filter, array( $this, $method ), $priority, $acceptedArgs );
}
/**
* @param string $action_or_filter
* @param string $method
* @param int $priority
* @param int $acceptedArgs
*/
protected function removeHookMethod( $action_or_filter, $method, $priority = 10, $acceptedArgs = 99 )
{
remove_filter( $action_or_filter, array( $this, $method ), $priority, $acceptedArgs );
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace Shy\WordPress;
/**
* Options wrapper.
*/
class Options implements \ArrayAccess
{
/**
* Remember a default value.
*
* Creates a closure and hooks it into the 'default_option_*' filter.
*
* @param string $option
* @param mixed $value
*/
public function setDefault( $option, $value )
{
add_filter( 'default_option_' . $option, function () use ( $value ) {
return $value;
} );
}
public function offsetExists( $option )
{
// Unfortunately, we cant really tell whether it exists…
$value = get_option( $option );
return false !== $value && null !== $value;
}
public function offsetGet( $option )
{
return get_option( $option );
}
public function offsetSet( $option, $value )
{
update_option( $option, $value );
}
public function offsetUnset( $option )
{
delete_option( $option );
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Shy\WordPress;
/**
* Marker class for WordPress plugins.
*/
abstract class Plugin
{
use HookableTrait;
}

View File

@ -0,0 +1,378 @@
<?php
namespace Shy\WordPress;
/**
* Abstracts common functionality and escaping for the Settings API.
*
* TODO: Check slug and field names for illegal characters.
* TODO: Refactor to not extend but use CompositeOption
*/
abstract class SettingsPage extends CompositeOption
{
use HookableTrait;
/**
* @var string
*/
protected $capability;
/**
* Slug (file name) of the parent menu entry.
*
* @see add_submenu_page() for suggestions.
*
* @return string|null
*/
protected function getParentSlug()
{
return 'options-general.php';
}
/**
* Title for this setting page.
*
* @return string
*/
abstract protected function getPageTitle();
/**
* String to show in the menu entry.
*
* @return string
*/
protected function getMenuTitle()
{
return $this->getPageTitle();
}
/**
* @param string $slug Page slug
* @param string $capability Required capability to view
*/
protected function __construct( $slug, $capability = 'manage_options' )
{
parent::__construct( $slug );
$this->capability = (string) $capability;
$this->addHookMethod( 'admin_menu', 'registerPage' );
$this->addHookMethod( 'admin_init', 'registerSettings' );
}
/**
* Register our options page.
*
* @return void
*/
public function registerPage()
{
add_submenu_page(
$this->getParentSlug(),
$this->getPageTitle(),
$this->getMenuTitle(),
$this->capability,
$this->slug,
array( $this, 'renderPage' )
);
}
/**
* Register the actual settings.
* Override and use addSection() and add*Field() methods.
*
* @return void
*/
public function registerSettings()
{
register_setting(
$this->slug,
$this->slug,
array( $this, 'sanitizeOptions' )
);
}
/**
* Sanitize option values after form submission.
*
* @param array $options
* @return array
*/
abstract public function sanitizeOptions( array $options );
/**
* Section to add fields to.
*
* Parameter default from add_settings_field().
*
* @var string
*/
protected $currentSection = 'default';
/**
* Add a new section and return its generated name.
*
* @param string $title optional, can be empty
* @param string $name optional, will be generated if empty
* @return string
*/
protected function addSection( $title = '', $name = '' )
{
$name = (string) $name;
if ( ! strlen( $name ) ) {
$name = $this->slug . '-section' . ( count( $this->getSections() ) + 1 );
}
add_settings_section(
$name,
esc_html( $title ),
array( $this, 'renderSectionTeaser' ),
$this->slug
);
return $this->currentSection = $name;
}
/**
* Callback before output of section fields.
*
* Teasers must escape their output themselves.
*
* @param array $section {
* @type string $id
* @type string $title
* @type callable $callback
* }
*/
public function renderSectionTeaser( array $section )
{
}
/**
* Get all known section names on this page.
*
* @global $wp_settings_fields
* @return array<string>
*/
public function getSections()
{
global $wp_settings_fields;
if ( ! isset( $wp_settings_fields[ $this->slug ] ) ) {
return array();
}
return array_keys( $wp_settings_fields[ $this->slug ] );
}
/**
* @global $wp_settings_fields
* @param string $section
* @return array<string, array {
* @type string $id
* @type string $title
* @type callable $callback
* @type array $args
* }>
*/
public function getFieldsForSection( $section )
{
global $wp_settings_fields;
return $wp_settings_fields[ $this->slug ][ $section ];
}
/**
* Add a custom field to this setting page.
*
* @param string $name
* @param string $label
* @param callable $callback
* @param array $args
*/
protected function addField( $name, $label, $callback, $args = array() )
{
if ( ! is_callable( $callback ) ) {
throw new \InvalidArgumentException( 'Parameter $callback must be callable.' );
}
add_settings_field(
$name,
esc_html( $label ),
$callback,
$this->slug,
$this->currentSection,
$args
);
}
/**
* Add a text field to this settings page.
*
* @param string $name
* @param string $label
* @param array $args
* @param string $callback
*/
protected function addTextField( $name, $label, $args = array(), $callback = '' )
{
if ( ! $callback || ! is_callable( $callback ) ) {
$callback = array( $this, 'renderTextField' );
}
$this->addField(
$name,
$label,
$callback,
$args + array(
'label_for' => $this->slug . '-' . $name,
'name' => $name,
'attr' => array(),
)
);
}
/**
* @param string $name
* @param string $label
* @param string $caption
* @param array $args
* @param callable $callback
*/
protected function addCheckboxField( $name, $label, $caption, $args = array(), $callback = '' )
{
if ( ! $callback || ! is_callable( $callback ) ) {
$callback = array( $this, 'renderCheckboxField' );
}
$this->addField(
$name,
$label,
$callback,
$args + array(
'label_for' => $this->slug . '-' . $name,
'name' => $name,
'caption' => $caption,
'attr' => array(),
)
);
}
/**
* Add an error.
*
* @param string $code
* @param string $message
*/
protected function addError( $code, $message )
{
add_settings_error( $this->slug, $code, $message );
}
/**
* Errors for this setting.
*
* @return array {
* @type string $setting
* @type string $code
* @type string $message
* @type string $type 'error'
* }
*/
public function getErrors()
{
return get_settings_errors( $this->slug );
}
/**
* Render a setting as text field.
*
* @param array $args {
* @type string $name
* @type string $label_for
* @type array $attr
* }
*/
public function renderTextField( array $args )
{
$name = $args['name'];
$this->renderInputTag( array(
'type' => 'text',
'id' => $args['label_for'],
'class' => 'regular-text',
'name' => $this->slug . '[' . $name . ']',
'value' => $this[ $name ],
) + $args['attr'] );
}
/**
* Render a setting as checkbox.
*
* @param array $args {
* @type string $caption
* @type string $name
* @type string $label_for
* @type array $attr
* }
*/
public function renderCheckboxField( array $args )
{
$name = $args['name'];
echo '<label>';
$this->renderInputTag( array(
'type' => 'checkbox',
'id' => isset( $args['label_for'] ) ? $args['label_for'] : null,
'name' => $this->slug . '[' . $name . ']',
'value' => '1',
'checked' => $this[ $name ] ? 'checked' : null,
) + $args['attr'] );
echo ' ' . esc_html( $args['caption'] ) . '</label>';
}
/**
* Output an input tag with given HTML attributes.
*
* @param array $attr
*/
protected function renderInputTag( array $attr )
{
echo '<input';
foreach ( $attr as $k => $v ) {
if ( null !== $v ) {
printf( ' %s="%s"', $k, esc_attr( $v ) );
}
}
echo ' />';
}
/**
* Output settings page.
*/
public function renderPage()
{
if ( ! current_user_can( $this->capability ) ) {
wp_die( __( 'You do not have sufficient permissions to access this page.' ) );
}
?>
<div class="wrap">
<h2><?php echo esc_html( $this->getPageTitle() ); ?></h2>
<form action="options.php" method="post">
<?php settings_errors( 'general' ); // “Settings saved.” message ?>
<?php settings_fields( $this->slug ); ?>
<?php do_settings_sections( $this->slug ); ?>
<?php submit_button(); ?>
</form>
</div>
<?php
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace Shy\WordPress;
/**
* Site options wrapper.
*/
class SiteOptions implements \ArrayAccess
{
/**
* Remember a default value.
*
* Creates a closure and hooks it into the 'default_site_option_*' filter.
*
* @param string $option
* @param mixed $value
*/
public function setDefault( $option, $value )
{
add_filter( 'default_site_option_' . $option, function () use ( $value ) {
return $value;
} );
}
public function offsetExists( $option )
{
// Unfortunately, we cant really tell whether it exists…
$value = get_site_option( $option );
return false !== $value && null !== $value;
}
public function offsetGet( $option )
{
return get_site_option( $option );
}
public function offsetSet( $option, $value )
{
update_site_option( $option, $value );
}
public function offsetUnset( $option )
{
delete_site_option( $option );
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Shy\WordPress;
abstract class Theme extends Plugin
{
public function __construct()
{
$GLOBALS['content_width'] = $this->getContentWidth();
}
/**
* @return integer
*/
abstract public function getContentWidth();
}

View File

@ -0,0 +1,20 @@
<?php
/**
* Try to load a Shy WordPress class.
*
* @param string $name
* @return boolean
*/
function shy_wordpress_autoloader( $name )
{
if ( substr( $name, 0, 14 ) !== 'Shy\\WordPress\\' ) {
return false;
}
$name = __DIR__ . '/' . str_replace( '\\', DIRECTORY_SEPARATOR, $name ) . '.php';
return is_file( $name ) && include( $name );
}
spl_autoload_register( 'shy_wordpress_autoloader' );

View File

@ -0,0 +1,40 @@
<?php
namespace Shy\WordPress\Tests;
use Shy\WordPress\HookableTrait;
/**
* Check that HookableTrait actually works.
*
* @author Philipp Cordes <pc@irgendware.net>
*/
class HookableTraitTest extends \WP_UnitTestCase
{
use HookableTrait;
public function actionMethod()
{
}
public function testWorksAsAction()
{
$this->addHookMethod( 'shywp_test_action', 'actionMethod' );
$this->assertTrue( has_action( 'shywp_test_action' ), 'Registering an action via addHookMethod() worked.' );
}
public function filterMethod( $value )
{
return $value;
}
public function testWorksAsFilter()
{
$this->addHookMethod( 'shywp_test_filter', 'filterMethod' );
$this->assertTrue( has_filter( 'shywp_test_filter' ), 'Registering a filter via addHookMethod() worked.' );
}
}

View File

@ -0,0 +1,180 @@
<?php
namespace Shy\WordPress\Tests;
use Shy\WordPress\SettingsPage;
use PHPUnit_Framework_MockObject_MockObject as MockObject;
use PHPUnit_Framework_MockObject_Builder_InvocationMocker as BuilderInvocationMocker;
class SettingsPageTest extends \WP_UnitTestCase
{
/**
* Mock a SettingsPage.
*
* @param string|null $slug
* @param string $capability
* @return SettingsPage|MockObject {
* @method BuilderInvocationMocker method(string)
* }
*/
protected function mockSettingsPage( $slug = null, $capability = 'manage_options' )
{
$builder = $this->getMockBuilder( 'Shy\WordPress\SettingsPage' )
->enableProxyingToOriginalMethods();
if ( null === $slug ) {
$builder->disableOriginalConstructor();
} else {
$builder->setConstructorArgs( array( $slug, $capability ) );
}
return $builder->getMock();
}
/**
* Test reading defaults from the settings page.
*
* @covers SettingsPage::__construct()
* @covers SettingsPage::getDefaults()
* @covers SettingsPage::offsetExists()
* @covers SettingsPage::offsetGet()
* @expectedException OutOfBoundsException
*/
public function testReading()
{
$slug = 'shywp_settingspage_test_slug_reading';
$defaults = array( 'foo' => 'bar' );
$page = $this->mockSettingsPage( $slug );
$page->method( 'getDefaults' )->willReturn( $defaults );
$this->assertEquals( $defaults, get_option( $slug ) );
$this->assertArrayHasKey( 'foo', $page );
$this->assertEquals( $defaults['foo'], $page['foo'] );
$this->assertArrayNotHasKey( 'baz', $page );
$page['baz'];
}
/**
* Test writing to the settings page.
*
* @covers SettingsPage::offsetSet()
* @expectedException OutOfBoundsException
*/
public function testWriting()
{
$slug = 'shywp_settingspage_test_slug_writing';
$defaults = array( 'foo' => 'bar' );
$page = $this->mockSettingsPage( $slug );
$page->method( 'getDefaults' )->willReturn( $defaults );
$page['foo'] = 'foo';
$this->assertEquals( 'foo', $page['foo'] );
$page['baz'] = '123';
}
/**
* Fail to remove a setting.
*
* @covers SettingPage::offsetUnset()
* @expectedException BadMethodCallException
*/
public function testRemoving()
{
$slug = 'shywp_settingspage_test_slug_removing';
$defaults = array();
$page = $this->mockSettingsPage( $slug );
$page->method( 'getDefaults' )->willReturn( $defaults );
unset( $page['baz'] );
}
/**
* Test whether the settings page can be showed.
*
* @covers SettingsPage::__construct()
* @covers SettingsPage::getParentSlug()
* @covers SettingsPage::getPageTitle()
* @covers SettingsPage::getMenuTitle()
*/
public function testRegisterPage()
{
$this->expectOutputRegex( '/&lt;page&amp;title&gt;/' );
$slug = 'shywp_settingspage_test_slug_registerpage';
$page = $this->mockSettingsPage( $slug );
$page->method( 'getParentSlug' )->willReturn( 'index.php' );
$page->method( 'getPageTitle' )->willReturn( '<page&title>' );
$page->method( 'getMenuTitle' )->willReturn( '<menu&title>' );
$page->expects( $this->once() )->method( 'registerPage' )->with();
$page->expects( $this->once() )->method( 'registerSettings' )->with();
// FIXME: Simulate display of backend.
}
/**
* @covers SettingsPage::sanitizeOptions()
*/
public function testSanitize()
{
$slug = 'shywp_settingspage_test_slug_sanitize';
$page = $this->mockSettingsPage( $slug );
$page->method( 'sanitizeOptions' )->will( $this->returnArgument( 0 ) );
$page->expects( $this->atLeastOnce() )->method( 'sanitizeOptions' );
$this->markTestIncomplete();
// FIXME: Simulate form submission
}
public function testRenderTextField()
{
$this->expectOutputRegex( '/^<input type="text"/' );
$page = $this->mockSettingsPage();
$page->renderTextField( array(
'label_for' => 'foo',
'name' => 'bar',
) );
}
public function testRenderCheckboxField()
{
$this->expectOutputRegex( '/^<label><input type="checkbox"/' );
$page = $this->mockSettingsPage();
$page->renderCheckboxField( array(
'label_for' => 'foo',
'name' => 'bar',
'caption' => 'baz',
) );
}
public function testRenderPage()
{
$this->markTestIncomplete();
$this->expectOutputRegex( '/<form action="options.php" method="post">.*&lt;3&amp;&gt;.*cryptic_teaser.*</form>/' );
$slug = 'shywp_settingspage_test_slug_renderpage';
$page = $this->mockSettingsPage( $slug, 'read' );
$page->method( 'getPageTitle' )->willReturn( '<3&>' );
$page->method( 'renderSectionTeaser' )->will( $this->returnCallback( function () use ( $teaser ) {
echo 'cryptic_teaser';
} ) );
// FIXME: Simulate view of the settings page
$page->renderPage();
}
}

View File

@ -0,0 +1,20 @@
<?php
/**
* Try to load a Shy WordPress test class.
*
* @param string $name
* @return boolean
*/
function shy_wordpress_tests_autoloader( $name )
{
if ( substr( $name, 0, 20 ) !== 'Shy\\WordPress\\Tests\\' ) {
return false;
}
$name = __DIR__ . '/' . str_replace( '\\', DIRECTORY_SEPARATOR, $name ) . '.php';
return is_file( $name ) && include( $name );
}
spl_autoload_register( 'shy_wordpress_tests_autoloader' );

View File

@ -0,0 +1,14 @@
<?php
/**
* PHPUnit bootstrap file
*
* Variant of the one from github.com/tierra/wordpress-plugins-tests
*/
require_once '../src/autoloader.php';
require_once 'autoloader.php';
require_once ( getenv( 'WP_DEVELOP_DIR' ) ?: '../../../..' )
. '/tests/phpunit/includes/bootstrap.php';