Skip to content

Commit 1aa86cf

Browse files
namedgraphclaude
andcommitted
A UI test for a chart's series order; ldh add result-set-chart takes several series
The new spec seeds a Table chart declaring four series in the reverse of the order its query projects them, and asserts the header row follows the projection. Without the chart.xsl fix it fails (shortest, chars, items where items, chars, shortest belong); with it, it passes as owner and anonymous. Seeding that chart needed --series-var-name to repeat, which a Timeline needs anyway (start and end at least): Views.resultSetChart takes a list, and the single-series signature delegates to it so Web-Algebra's caller is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent afc8c99 commit 1aa86cf

6 files changed

Lines changed: 104 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- Views defer their whole-result-set queries until they scroll into sight, and load their metadata in parallel with the document's
1111
- Discovering a view's link predicates is faster
1212
- The `ldh` plugin is named LinkedDataHub, has an icon and documents where it sends data
13+
- `ldh add result-set-chart` takes `--series-var-name` more than once, for charts with several series
1314

1415
### Fixed
1516
- An external document whose RDF could not be retrieved opens as a page instead of an error

‎cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddResultSetChart.java‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import com.atomgraph.linkeddatahub.cli.BaseCommand;
2020
import com.atomgraph.linkeddatahub.rdf.Views;
2121
import java.net.URI;
22+
import java.util.List;
2223
import picocli.CommandLine.Command;
2324
import picocli.CommandLine.Option;
2425
import picocli.CommandLine.Parameters;
@@ -50,16 +51,16 @@ public class AddResultSetChart extends BaseCommand
5051
@Option(names = "--category-var-name", required = true, paramLabel = "VAR_NAME", description = "Name of the category variable")
5152
private String categoryVarName;
5253

53-
@Option(names = "--series-var-name", required = true, paramLabel = "VAR_NAME", description = "Name of the series variable")
54-
private String seriesVarName;
54+
@Option(names = "--series-var-name", required = true, paramLabel = "VAR_NAME", description = "Name of a series variable (repeatable; the chart orders them as the query projects them)")
55+
private List<String> seriesVarNames;
5556

5657
@Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
5758
private URI target;
5859

5960
@Override
6061
public Integer call() throws Exception
6162
{
62-
post(getClient(), target, Views.resultSetChart(target, uri, title, query, chartType, categoryVarName, seriesVarName, description));
63+
post(getClient(), target, Views.resultSetChart(target, uri, title, query, chartType, categoryVarName, seriesVarNames, description));
6364
print(target);
6465

6566
return 0;

‎plugins/ldh/skills/app/SKILL.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ ORDER BY DESC(?productCount)""" .
121121
resources need `geo:lat`/`geo:long`), `ac:GraphMode`.
122122
- Chart types (`ldh:chartType`): `ac:BarChart`, `ac:LineChart`, `ac:ScatterChart`.
123123
`ldh:categoryVarName` and `ldh:seriesVarName` name the query's variables, without `?`.
124+
`ldh:seriesVarName` may repeat (`--series-var-name` too); the series are drawn in the order the
125+
query's `SELECT` projects them.
124126
- A query runs against the dataspace's own endpoint unless it names a service:
125127
`<#q> ldh:service <#dbpedia>` with `<#dbpedia> a sd:Service ; dct:title "DBpedia" ;
126128
sd:endpoint <https://dbpedia.org/sparql> ; sd:supportedLanguage sd:SPARQL11Query`. Time a remote

‎rdf/src/main/java/com/atomgraph/linkeddatahub/rdf/Views.java‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import com.atomgraph.linkeddatahub.rdf.vocabulary.LDH;
2121
import com.atomgraph.linkeddatahub.rdf.vocabulary.SPIN;
2222
import java.net.URI;
23+
import java.util.List;
2324
import org.apache.jena.rdf.model.Model;
2425
import org.apache.jena.rdf.model.ModelFactory;
2526
import org.apache.jena.rdf.model.Resource;
@@ -76,6 +77,26 @@ public static Model view(URI target, String uri, URI query, String title, String
7677
* @return chart model
7778
*/
7879
public static Model resultSetChart(URI target, String uri, String title, URI query, URI chartType, String categoryVarName, String seriesVarName, String description)
80+
{
81+
return resultSetChart(target, uri, title, query, chartType, categoryVarName, List.of(seriesVarName), description);
82+
}
83+
84+
/**
85+
* Builds a chart of a SELECT query's result set with one or more series. The values of
86+
* <code>ldh:seriesVarName</code> are unordered, so the chart orders its series by the query's
87+
* projection, not by this list.
88+
*
89+
* @param target target document URI
90+
* @param uri chart URI, relative or absolute (optional, blank node when absent)
91+
* @param title chart title
92+
* @param query URI of the query the chart runs
93+
* @param chartType chart type URI
94+
* @param categoryVarName result-set variable name providing the categories
95+
* @param seriesVarNames result-set variable names providing the series
96+
* @param description chart description (optional)
97+
* @return chart model
98+
*/
99+
public static Model resultSetChart(URI target, String uri, String title, URI query, URI chartType, String categoryVarName, List<String> seriesVarNames, String description)
79100
{
80101
Model model = ModelFactory.createDefaultModel();
81102

@@ -84,8 +105,8 @@ public static Model resultSetChart(URI target, String uri, String title, URI que
84105
addProperty(DCTerms.title, title).
85106
addProperty(SPIN.query, model.createResource(query.toString())).
86107
addProperty(LDH.chartType, model.createResource(chartType.toString())).
87-
addProperty(LDH.categoryVarName, categoryVarName).
88-
addProperty(LDH.seriesVarName, seriesVarName);
108+
addProperty(LDH.categoryVarName, categoryVarName);
109+
for (String seriesVarName : seriesVarNames) chart.addProperty(LDH.seriesVarName, seriesVarName);
89110
if (description != null) chart.addProperty(DCTerms.description, description);
90111

91112
return model;

‎tests/ui/lib/fixtures.mjs‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ export const fixtures = {
7373
// hold; a sibling of the container, so the document tree's item count does too.
7474
remoteHost: `${endUserBase}${slug}-remote/`,
7575
remoteBlock: `${endUserBase}${slug}-remote/#remote-block`,
76+
// A chart of several series, in a document of its own so the container's block count holds.
77+
// ldh:seriesVarName is multi-valued and RDF keeps no order among the values, so the chart has to
78+
// take its column order from the query's projection; see seriesQuery below.
79+
seriesDocument: `${endUserBase}${slug}-series/`,
80+
seriesQuery: `${endUserBase}${slug}-series/#measures-query`,
81+
seriesChart: `${endUserBase}${slug}-series/#measures-chart`,
82+
seriesBlock: `${endUserBase}${slug}-series/#measures-block`,
7683
};
7784

7885
// The embedded resource itself, in the second end-user dataspace the preflight resolved. Functions
@@ -191,6 +198,24 @@ WHERE
191198
GROUP BY ?kind
192199
ORDER BY ?kind`;
193200

201+
// Four series, so that a chart ordering them by anything but the projection matches it by chance
202+
// one time in 24 rather than one in two. Projected in this order, declared in the reverse.
203+
export const seriesProjection = ['items', 'chars', 'shortest', 'longest'];
204+
205+
const seriesQuery = `PREFIX sioc: <http://rdfs.org/sioc/ns#>
206+
PREFIX dct: <http://purl.org/dc/terms/>
207+
208+
SELECT ?kind (COUNT(?item) AS ?items) (SUM(STRLEN(?title)) AS ?chars) (MIN(STRLEN(?title)) AS ?shortest) (MAX(STRLEN(?title)) AS ?longest)
209+
WHERE
210+
{ GRAPH ?g
211+
{ ?item sioc:has_container <${fixtures.container}> ;
212+
dct:title ?title ;
213+
dct:description ?kind
214+
}
215+
}
216+
GROUP BY ?kind
217+
ORDER BY ?kind`;
218+
194219
export async function seed() {
195220
const container = await ldh(['create', 'container',
196221
'--parent', endUserBase, '--title', containerTitle, '--slug', slug]);
@@ -242,6 +267,25 @@ export async function seed() {
242267
await ldh(['add', 'object-block', '--title', 'Fixture query block', '--uri', fixtures.queryBlock,
243268
'--value', fixtures.query, fixtures.queryDocument]);
244269

270+
await ldh(['create', 'item',
271+
'--container', endUserBase,
272+
'--title', 'Fixture series',
273+
'--slug', `${slug}-series`]);
274+
const seriesQueryFile = join(mkdtempSync(join(tmpdir(), 'ui-tests-')), 'measures.rq');
275+
writeFileSync(seriesQueryFile, seriesQuery);
276+
await ldh(['add', 'select', '--title', 'Fixture item measures',
277+
'--uri', fixtures.seriesQuery, '--query-file', seriesQueryFile, fixtures.seriesDocument]);
278+
// A Table, because its header row IS the column order: a chart type draws the same columns as
279+
// bars and legend entries, which say nothing about position.
280+
await ldh(['add', 'result-set-chart', '--title', 'Fixture measures chart',
281+
'--uri', fixtures.seriesChart, '--query', fixtures.seriesQuery,
282+
'--chart-type', 'https://w3id.org/atomgraph/client#Table',
283+
'--category-var-name', 'kind',
284+
...seriesProjection.toReversed().flatMap(name => ['--series-var-name', name]),
285+
fixtures.seriesDocument]);
286+
await ldh(['add', 'object-block', '--title', 'Fixture measures block', '--uri', fixtures.seriesBlock,
287+
'--value', fixtures.seriesChart, fixtures.seriesDocument]);
288+
245289
// The cross-origin pair: a document in the other dataspace, created by the same owner (the root
246290
// owner owns every dataspace of the stack, the entrypoint sees to that), and the host here that
247291
// embeds it. The remote grant is what makes the embedded rendering an anonymous reader's to
@@ -275,6 +319,7 @@ export async function seed() {
275319
'--to', fixtures.container,
276320
'--to', fixtures.readable,
277321
'--to', fixtures.remoteHost,
322+
'--to', fixtures.seriesDocument,
278323
'--read',
279324
adminBase]);
280325

@@ -305,6 +350,7 @@ export async function teardown() {
305350
}
306351
await ldh(['delete', fixtures.private], { allowFailure: true });
307352
await ldh(['delete', fixtures.queryDocument], { allowFailure: true });
353+
await ldh(['delete', fixtures.seriesDocument], { allowFailure: true });
308354
await ldh(['delete', fixtures.remoteHost], { allowFailure: true });
309355
// Only once the preflight has resolved the remote dataspace: a teardown that runs before it
310356
// (UI_TESTS_SKIP_SEED, or a run that failed earlier in the preflight) has nothing to remove there.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// The chart block's series, in the order the query projects them.
2+
//
3+
// ldh:seriesVarName is multi-valued, and RDF keeps no order among a property's values, so the page
4+
// lists the series in whatever order the server happened to serialize them. Some charts read their
5+
// columns by position: a Timeline is row label, bar label, start, end. LTLOD's timeline declared
6+
// "seats", "start", "end", was served end, start, seats, and Google Charts refused it with "Invalid
7+
// data table format: column #1 must be of type 'string'" - in production, with nothing here to
8+
// notice, because every fixture chart had a single series.
9+
//
10+
// The fixture declares four series in the reverse of the order its query projects them, and draws
11+
// them as a Table, whose header row is the DataTable's column order read straight off the page.
12+
// Four, because a chart ordering by anything but the projection still matches it by chance one
13+
// time in 24; with two it would pass half the time without the fix.
14+
import { test, expect } from '../../../../lib/console.mjs';
15+
import { goto } from '../../../../lib/settle.mjs';
16+
import { fixtures, seriesProjection } from '../../../../lib/fixtures.mjs';
17+
import { canvas, chartBlock } from '../../../../lib/chart.mjs';
18+
19+
test('takes its series in the order the query projects them, not the order they are declared in', async ({ page }) => {
20+
await goto(page, fixtures.seriesDocument);
21+
22+
const block = chartBlock(page, fixtures.seriesChart);
23+
const table = canvas(block).locator('.google-visualization-table');
24+
await expect(table).toBeVisible({ timeout: 30_000 });
25+
26+
// The category first, then the series as SELECT lists them.
27+
await expect(table.locator('tr').first().locator('th')).toHaveText(['kind', ...seriesProjection]);
28+
});

0 commit comments

Comments
 (0)