Descriptors and Values are the two core architectural concepts in Quixote.
- Descriptors represent some aspect of your page, such as the width of an element. They have the ability to compute the value of that thing (the
value()
method) and the ability to describe that thing (thetoString()
method). - Values contain calculated values.
This file describes how to create a descriptor class.
Implement a descriptor class by following these steps.
- Create testbed.
- Provide factory methods.
- Extend
Descriptor
base class. - Compute values:
value()
. - Render to a string:
toString()
. - (Optional) Convert primitives:
convert()
. - Expose properties.
- (Optional) Add API.
The following example implements the (as yet fictional) BackgroundColor
descriptor. It represents the background color of an element and corresponds to the background-color
CSS property.
For a real descriptor example, see any of the descriptors in this directory. ElementEdge
and its tests are a good choice.
Start out by creating a test file for your descriptor. As you follow the example, leave out the comments.
"use strict"; // always use strict mode
// Our custom test assertion library
var assert = require("../util/assert.js");
// For speed, we reuse the same test frame, containing a reset stylesheet, across all our Quixote tests.
var reset = require("../__reset.js");
// The base class we'll be extending. In some cases, you'll extend a subclass of Descriptor, such as
// SizeDescriptor or PositionDescriptor. In that case, you'd require that class here instead.
var Descriptor = require("./descriptor.js");
// Our class under test
var BackgroundColor = require("./background_color.js");
// It's important to use the "DESCRIPTOR" tag. Otherwise, the build won't run the test.
describe("DESCRIPTOR: BackgroundColor", function() {
// Normally, you'd need a beforeEach() function to reset the test frame. But our
// __reset.js file implements that for us.
it("runs tests", function() {
// make sure the tests run (and fail)
assert.fail("hi");
});
});
Stub in the production code as well.
"use strict"; // always use strict mode
// Our runtime assertion library. We mostly use it for runtime signature type checking.
var ensure = require("../util/ensure.js");
// The base class we'll be extending. If you're extending a subclass of Descriptor, such as
// SizeDescriptor or PositionDescriptor, require that instead.
var Descriptor = require("./descriptor.js");
// We'll implement the rest of the class later.
We have a convention of using factory methods, not constructors, to instantiate all descriptors and values. The factory methods use a normal constructor under the covers, but other code is expected to use the factory.
Design the signature for your factory method, then implement a utility function in your test that calls the factory method. Your test's utility function will typically need to create an element for the descriptor to use.
In the case of our BackgroundColor example, the design of our factory method is simple: create(element)
.
⋮
var ELEMENT_NAME = "element";
var IRRELEVANT = "#abcdef";
⋮
it("runs tests", function() {
// Call the utility method. We're not making any assertions yet because this test is still temporary.
color(IRRELEVANT);
});
// We have a convention of putting our tests' utility functions at the bottom of the file.
function color(backgroundColor) {
// Create a test element for our descriptor to use
element = reset.frame.add(
"<p id='element' style='background-color: " + backgroundColor + "'>element</p>",
ELEMENT_NAME
);
// Create the descriptor and return it
return BackgroundColor.create(element);
}
The test will fail because the factory method doesn't exist. Implement it and its constructor.
⋮
// The constructor always comes first (after require statements). This is our convention for
// constructors. Be sure to include the function name. Even though it isn't technically required,
// we include it because it makes stack traces more readable.
var Me = module.exports = function BackgroundColor(element) {
// We need to type-check our signature. To do that, we need the QElement constructor. Normally,
// we'd require it at the top of the file, but in the case of QElement, that creates a circular
// dependency. So we need to require QElement here.
var QElement = require("./q_element.js");
// Check that the constructor was called correctly.
ensure.signature(arguments, [ QElement ]);
// Store the element for later
this._element = element;
};
Me.create = function(element) {
// Our factory method. It just calls the constructor. More complicated descriptors might do more.
// We don't call 'ensure.signature()' here because the constructor already does that.
return new Me(element);
};
All descriptors have to extend Descriptor
or another base class (such as SizeDescriptor
) in order to work properly.
Our tests:
⋮
// Replace the temporary 'runs tests' test with this new test
it("is a descriptor", function() {
// replace the 'runs tests' test with this one
assert.implements(color(IRRELEVANT), Descriptor);
});
⋮
Our production code:
var Me = module.exports = function BackgroundColor(element) {
⋮
};
// extend the base class. If you're extending another base class (such as `SizeDescriptor`), use that instead.
Descriptor.extend(Me);
⋮
// Temporary methods so the tests pass. We use `ensure.unreachable()` so we get a nice error message
// in case we forget to implement them later.
Me.prototype.value = function() {
ensure.unreachable();
};
Me.prototype.toString = function() {
ensure.unreachable();
};
This is where the magic happens. Descriptors represent some part of a page. They can compute the value of that part of the page on demand. Those values are returned as Value object instances, not primitives.
In the case of our BackgroundColor
descriptor, it represents the background color of an element. In the value()
method, it will compute the color. We're assuming that the Color
value object has already been implemented. (See the Value class tutorial for that example.)
var Color = require("../values/color.js");
⋮
var RED = "#ff0000";
⋮
it("resolves to value", function() {
// The `objEqual` assertion calls `.equals`, like this: `color.value().equals(Color.create(COLOR))`
// We're checking that the descriptor returns the correct Color value object.
assert.objEqual(color(RED).value(), Color.create(RED));
});
We implement it by getting background-color
from our element. Note that value()
always returns a value object, never a primitive.
Me.prototype.value = function() {
// check parameters
ensure.signature(arguments, []);
// get the style
var style = this._element.getRawStyle("background-color");
// convert it to a value object and return
return Color.create(style);
};
Descriptors have the ability to describe, in human-readable terms, which part of the page they represent. This human-readable description will be used in assertions.
In the case of our BackgroundColor
example, a good value for toString()
might be something like "background color of 'element'".
it("renders to string", function() {
assert.equal(color(IRRELEVANT).toString(), "background color of " + ELEMENT_NAME);
});
Me.prototype.toString = function() {
// check parameters
ensure.signature(arguments, []);
return "background color of " + this._element;
};
If the user tries to compare our descriptor to a primitive type, convert()
will be called by the Descriptor
base class. Any type we support should be converted to a value object here. The value object should do the parsing, so all this function needs to do is decide which factory method to invoke.
Any type that isn't supported should be ignored (resulting in undefined
being returned). The base class turns undefined
results into a nice error message.
If you use one of the pre-built descriptor base classes (such as SizeDescriptor
), this method may already be implemented for you.
Our BackgroundColor
example converts a string to a Color
value, but ignores everything else:
it("converts comparison arguments", function() {
assert.objEqual(color.convert("#aabbcc", "string"), Color.create("#aabbcc"));
});
Me.prototype.convert = function(arg, type) {
// We don't check the signature on this method because it's strictly for internal use.
if (type === "string") return Color.create(arg);
};
For a descriptor to be accessible by users, it must be exposed with a property on QElement
or another object.
For our BackgroundColor
example, we'll add a QElement.backgroundColor
property. The tests and code are simple because the heavy lifting is done in the descriptor.
In the QElement tests, we add a test to the "properties" describe
block:
describe("properties", function() {
⋮
it("colors", function() {
assert.equal(element.backgroundColor.diff(COLOR), "", "background color");
});
And in the QElement constructor, we create the property:
var Me = module.exports = function QElement(domElement, qframe, nickname) {
⋮
this.backgroundColor = BackgroundColor.create(this);
Your descriptor is done.
Next, think about how you can add properties to this descriptor (just as with the last step above) that allow it to be more useful. You might provide a property that exposes an existing descriptor or create a new descriptor that modifies this one. For example, we might want to add a darken()
method to BackgroundColor
that returns a RelativeColor
descriptor. If you extended a pre-built descriptor base class (such as SizeDescriptor
), this has probably already been done for you.
For a complete descriptor example, see ElementSize
and its tests.