weeks of bullshit

This commit is contained in:
Relism
2026-04-17 08:18:11 +02:00
parent b5d4481502
commit 9efbe38c0c
157 changed files with 5171 additions and 1273 deletions
@@ -0,0 +1,5 @@
package dev.relism.ext.view;
public record EngineCapabilities(boolean supportsPartialSlot) {
public static final EngineCapabilities NONE = new EngineCapabilities(false);
}
@@ -0,0 +1,38 @@
package dev.relism.ext.view;
import dev.relism.http.ContentType;
import dev.relism.models.Request;
import dev.relism.models.Response;
import java.util.Map;
final class LegacyViewEngineAdapter implements ViewEngineAdapter {
private static final String DEFAULT_FRAGMENT_SLOT = "content";
private final ViewEngine engine;
LegacyViewEngineAdapter(ViewEngine engine) {
this.engine = engine;
}
@Override
public EngineCapabilities capabilities() {
return EngineCapabilities.NONE;
}
@Override
public RenderOutput render(ViewTarget target, Map<String, Object> model, Request req, Response res) throws Exception {
String template = target.template();
boolean fragment = target.kind() == ViewKind.PARTIAL;
if (fragment && target.slot() != null && !target.slot().isBlank()) {
template = template + " :: " + target.slot();
fragment = false;
} else if (fragment) {
template = template + " :: " + DEFAULT_FRAGMENT_SLOT;
fragment = false;
}
return new RenderOutput(engine.render(template, model, fragment), ContentType.TEXT_HTML);
}
}
@@ -0,0 +1,12 @@
package dev.relism.ext.view;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Page {
String value();
}
@@ -0,0 +1,13 @@
package dev.relism.ext.view;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Partial {
String template();
String slot() default "";
}
@@ -0,0 +1,9 @@
package dev.relism.ext.view;
import dev.relism.http.ContentType;
public record RenderOutput(String body, ContentType contentType) {
public static RenderOutput html(String body) {
return new RenderOutput(body, ContentType.TEXT_HTML);
}
}
@@ -54,7 +54,7 @@ public final class Renderer {
* header to {@code type}, and returns the rendered string as the handler body.
*/
public String view(Response res, String template, Object model, ContentType type) throws Exception {
res.setContentType(type);
res.type(type);
return engine.render(template, model);
}
@@ -41,7 +41,7 @@ import java.util.Map;
* <li>{@code null} model → empty context.</li>
* </ul>
*/
final class ThymeleafEngine implements ViewEngine {
final class ThymeleafEngine implements ViewEngine, ViewEngineAdapter {
private static final String PREFIX = "/templates/";
private static final String SUFFIX = ".html";
@@ -71,6 +71,27 @@ final class ThymeleafEngine implements ViewEngine {
return engine.process(fragment ? template + FRAGMENT : template, ctx);
}
@Override
public EngineCapabilities capabilities() {
return new EngineCapabilities(true);
}
@Override
public RenderOutput render(ViewTarget target, Map<String, Object> model,
dev.relism.models.Request req,
dev.relism.models.Response res) {
String selector;
if (target.kind() == ViewKind.PAGE) {
selector = target.template();
} else {
String slot = target.slot();
selector = target.template() + " :: " + ((slot == null || slot.isBlank()) ? "content" : slot);
}
Context ctx = new Context();
populateContext(ctx, model);
return RenderOutput.html(engine.process(selector, ctx));
}
private static void populateContext(Context ctx, Object model) {
if (model instanceof Map<?, ?> map) {
map.forEach((k, v) -> ctx.setVariable(String.valueOf(k), v));
@@ -0,0 +1,11 @@
package dev.relism.ext.view;
import dev.relism.models.Request;
import dev.relism.models.Response;
import java.util.Map;
public interface ViewEngineAdapter {
EngineCapabilities capabilities();
RenderOutput render(ViewTarget target, Map<String, Object> model, Request req, Response res) throws Exception;
}
@@ -53,15 +53,21 @@ public enum ViewEngineType {
* @param cacheEnabled whether the engine should cache compiled templates
* @throws IllegalStateException if the required library is not on the classpath
*/
ViewEngine createEngine(boolean cacheEnabled) {
ViewEngineAdapter createAdapter(boolean cacheEnabled) {
return switch (this) {
case THYMELEAF -> createThymeleaf(cacheEnabled);
};
}
ViewEngine createEngine(boolean cacheEnabled) {
ViewEngineAdapter adapter = createAdapter(cacheEnabled);
if (adapter instanceof ViewEngine engine) return engine;
throw new IllegalStateException("Selected engine does not expose legacy ViewEngine interface: " + this);
}
// ── Engine factories ──────────────────────────────────────────────────────
private static ViewEngine createThymeleaf(boolean cacheEnabled) {
private static ViewEngineAdapter createThymeleaf(boolean cacheEnabled) {
try {
return new ThymeleafEngine(cacheEnabled);
} catch (NoClassDefFoundError e) {
@@ -1,166 +1,81 @@
package dev.relism.ext.view;
import dev.relism.Flash;
import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashExtension;
import dev.relism.extension.FlashRegistrar;
import dev.relism.http.ContentType;
import dev.relism.routing.Middleware;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Installs the view layer into a Flash application.
*
* <h3>Managed mode (recommended)</h3>
* Pass a {@link ViewEngineType} — the extension auto-configures the engine,
* detects dev mode for cache settings, and validates classpath dependencies at boot:
* <pre>{@code
* app.install(new ViewExtension(ViewEngineType.THYMELEAF));
* }</pre>
*
* <h3>Manual mode (BYOE)</h3>
* Supply your own {@link ViewEngine} implementation for full control:
* <pre>{@code
* ViewEngine myEngine = (template, model, fragment) -> { ... };
* app.install(new ViewExtension(myEngine));
* }</pre>
*
* <h3>What gets installed</h3>
* <ol>
* <li>{@link ViewEngine} and {@link Renderer} are bound in the {@link FlashContext} —
* any handler can retrieve them via {@code require(Renderer.class)}.</li>
* <li>An {@link dev.relism.extension.AnnotationProcessor} is registered: class-based
* handlers carrying {@link View @View} receive an injected rendering middleware
* that intercepts the handler return value, resolves the template + model, and
* delegates to the engine. No boilerplate required in the handler itself.</li>
* </ol>
*
* <h3>Handler patterns</h3>
* <pre>{@code
* // Declarative — annotation drives template selection
* @Route(method = HttpMethod.GET, path = "/")
* @View("home")
* public class HomeHandler extends RequestHandler {
* public Object handle(Request req, Response res) {
* return Map.of("posts", service.findAll()); // model → home.html
* }
* }
*
* // Dynamic override via Template signal
* @View("list")
* public class ListHandler extends RequestHandler {
* public Object handle(Request req, Response res) {
* if (error) return Template.of("error", Map.of("msg", "oops"));
* return data; // falls back to list.html
* }
* }
*
* // Imperative — explicit render call (lambda-friendly)
* Renderer renderer = app.ctx().require(Renderer.class);
* app.get("/about", (req, res) -> renderer.view(res, "about"));
* }</pre>
*
* <h3>Dev mode / cache</h3>
* In managed mode, template caching is disabled when the JVM property
* {@code flash.env=dev} or the environment variable {@code FLASH_ENV=dev} is set.
*
* <h3>Cross-extension integration</h3>
* <pre>{@code
* ctx.optional(ViewEngine.class).ifPresent(engine -> { ... });
* }</pre>
*/
public final class ViewExtension implements FlashExtension {
private final ViewEngine engine;
private final ViewEngineAdapter adapter;
private final ViewEngine legacyEngine;
private final List<ViewGlobals> globals = new ArrayList<>();
// ── Constructors ──────────────────────────────────────────────────────────
/**
* Managed mode — auto-configures the engine selected by {@code type}.
*
* <p>Template caching is enabled unless {@code flash.env=dev} (JVM property)
* or {@code FLASH_ENV=dev} (environment variable) is set.
*
* @param type the engine to use; must have its library on the runtime classpath
* @throws IllegalStateException at boot time if the library is missing
*/
public ViewExtension(ViewEngineType type) {
this(type.createEngine(!isDevMode()));
this(type.createAdapter(!Flash.DEV));
}
/**
* Manual mode — use a pre-constructed {@link ViewEngine} implementation.
* Suitable for custom engines or engines that need non-default configuration.
*
* @param engine the engine implementation; must be thread-safe
*/
public ViewExtension(ViewEngine engine) {
this.engine = Objects.requireNonNull(engine, "ViewEngine must not be null");
this(new LegacyViewEngineAdapter(engine), engine);
}
// ── FlashExtension ────────────────────────────────────────────────────────
public ViewExtension(ViewEngineAdapter adapter) {
this(adapter, null);
}
private ViewExtension(ViewEngineAdapter adapter, ViewEngine legacyEngine) {
this.adapter = Objects.requireNonNull(adapter, "ViewEngineAdapter must not be null");
this.legacyEngine = legacyEngine;
}
public ViewExtension addGlobals(ViewGlobals provider) {
globals.add(Objects.requireNonNull(provider, "ViewGlobals must not be null"));
return this;
}
@Override
public void install(FlashRegistrar app, FlashContext ctx) {
Renderer renderer = new Renderer(engine);
ctx.provide(ViewEngine.class, engine);
ctx.provide(Renderer.class, renderer);
public void provide(FlashContext ctx) {
ViewRuntime runtime = new ViewRuntime(adapter, List.copyOf(globals));
ctx.provide(ViewEngineAdapter.class, adapter);
ctx.provide(ViewRuntime.class, runtime);
if (legacyEngine != null) {
ctx.provide(ViewEngine.class, legacyEngine);
ctx.provide(Renderer.class, new Renderer(legacyEngine));
} else if (adapter instanceof ViewEngine engine) {
ctx.provide(ViewEngine.class, engine);
ctx.provide(Renderer.class, new Renderer(engine));
}
ctx.addAnnotationProcessor(handlerClass -> {
View view = findView(handlerClass);
if (view == null) return List.of();
if (ViewHandler.class.isAssignableFrom(handlerClass)) return List.of();
String defaultTemplate = view.value();
ContentType contentType = view.contentType();
boolean fragment = view.fragment();
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(handlerClass, adapter.capabilities());
if (resolved == null) return List.of();
// Injected once per handler at boot — zero overhead on the hot-path.
// Intercepts the return value: Template signal overrides name+model;
// any other value becomes the model for the annotation's template.
Middleware renderingMiddleware = next -> (req, res) -> {
Object result = next.handle(req, res);
String tpl;
Object model;
if (result instanceof Template t) {
tpl = t.name();
model = t.model();
} else {
tpl = defaultTemplate;
model = result;
ViewModel local = ViewRuntime.legacyLocalModel(result);
ViewModel merged = runtime.merge(req, local);
RenderOutput out = adapter.render(resolved.target(), merged.toMap(), req, res);
if (resolved.legacy()) {
ContentType type = resolved.contentType();
if (type != null) res.type(type);
else if (out.contentType() != null) res.type(out.contentType());
} else if (out.contentType() != null) {
res.type(out.contentType());
}
res.setContentType(contentType);
return engine.render(tpl, model, fragment);
return out.body();
};
return List.of(renderingMiddleware);
});
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Walks the superclass chain to find {@link View @View}.
* Supports inheritance: a base handler can declare the view template and
* concrete subclasses inherit it without re-annotating.
*/
private static View findView(Class<?> cls) {
while (cls != null && !cls.equals(Object.class)) {
View v = cls.getAnnotation(View.class);
if (v != null) return v;
cls = cls.getSuperclass();
}
return null;
}
/**
* Returns {@code true} when running in dev mode.
* Checks JVM property {@code flash.env} first, then env var {@code FLASH_ENV}.
*/
private static boolean isDevMode() {
String prop = System.getProperty("flash.env");
if (prop != null) return "dev".equalsIgnoreCase(prop);
String env = System.getenv("FLASH_ENV");
return "dev".equalsIgnoreCase(env);
}
}
@@ -0,0 +1,8 @@
package dev.relism.ext.view;
import dev.relism.models.Request;
@FunctionalInterface
public interface ViewGlobals {
ViewModel provide(Request req);
}
@@ -0,0 +1,32 @@
package dev.relism.ext.view;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import dev.relism.models.Response;
public abstract class ViewHandler extends RequestHandler {
private ViewRuntime runtime;
private ViewTargetResolver.ResolvedView resolved;
public ViewModel render(Request req) throws Exception {
throw new UnsupportedOperationException("Override render(Request) or render(Request, Response)");
}
public ViewModel render(Request req, Response res) throws Exception {
return render(req);
}
@Override
protected final void onInit() {
runtime = require(ViewRuntime.class);
resolved = runtime.resolve(getClass());
onViewInit();
}
protected void onViewInit() {}
@Override
public final Object handle(Request request, Response response) throws Exception {
return runtime.render(this, resolved, request, response);
}
}
@@ -0,0 +1,6 @@
package dev.relism.ext.view;
public enum ViewKind {
PAGE,
PARTIAL
}
@@ -0,0 +1,71 @@
package dev.relism.ext.view;
import java.util.Collections;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class ViewModel {
private final LinkedHashMap<String, Object> values;
private ViewModel(LinkedHashMap<String, Object> values) {
this.values = values;
}
public static ViewModel empty() {
return new ViewModel(new LinkedHashMap<>());
}
public static ViewModel of(String key, Object value) {
return empty().with(key, value);
}
public ViewModel with(String key, Object value) {
values.put(Objects.requireNonNull(key, "key"), unwrapValue(value));
return this;
}
public ViewModel withAll(Map<String, Object> values) {
if (values == null || values.isEmpty()) return this;
for (Map.Entry<String, Object> e : values.entrySet()) {
with(e.getKey(), e.getValue());
}
return this;
}
public ViewModel merge(ViewModel other) {
ViewModel merged = new ViewModel(new LinkedHashMap<>(this.values));
if (other != null && !other.values.isEmpty()) merged.values.putAll(other.values);
return merged;
}
public Map<String, Object> toMap() {
return Collections.unmodifiableMap(values);
}
static ViewModel copyOf(ViewModel source) {
if (source == null || source.values.isEmpty()) return empty();
return new ViewModel(new LinkedHashMap<>(source.values));
}
static Object unwrapValue(Object value) {
if (value instanceof ViewModel vm) {
return vm.toMap();
}
if (value instanceof Map<?, ?> map) {
LinkedHashMap<String, Object> out = new LinkedHashMap<>();
for (Map.Entry<?, ?> e : map.entrySet()) {
out.put(String.valueOf(e.getKey()), unwrapValue(e.getValue()));
}
return Collections.unmodifiableMap(out);
}
if (value instanceof List<?> list) {
ArrayList<Object> out = new ArrayList<>(list.size());
for (Object item : list) out.add(unwrapValue(item));
return Collections.unmodifiableList(out);
}
return value;
}
}
@@ -0,0 +1,79 @@
package dev.relism.ext.view;
import dev.relism.models.Request;
import dev.relism.models.Response;
import java.util.concurrent.ConcurrentHashMap;
import java.util.List;
import java.util.Map;
final class ViewRuntime {
private static final ViewModel EMPTY = ViewModel.empty();
private final ViewEngineAdapter adapter;
private final List<ViewGlobals> globals;
private final ConcurrentHashMap<Class<?>, ViewTargetResolver.ResolvedView> resolvedCache = new ConcurrentHashMap<>();
ViewRuntime(ViewEngineAdapter adapter, List<ViewGlobals> globals) {
this.adapter = adapter;
this.globals = globals;
}
ViewTargetResolver.ResolvedView resolve(Class<?> handlerClass) {
ViewTargetResolver.ResolvedView cached = resolvedCache.get(handlerClass);
if (cached != null) return cached;
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(handlerClass, adapter.capabilities());
if (resolved == null) {
throw new IllegalStateException("ViewHandler " + handlerClass.getName()
+ " must declare @Page, @Partial, or @View");
}
resolvedCache.put(handlerClass, resolved);
return resolved;
}
Object render(ViewHandler handler, ViewTargetResolver.ResolvedView resolved, Request req, Response res) throws Exception {
if (resolved == null) {
throw new IllegalStateException("ViewHandler " + handler.getClass().getName()
+ " must declare @Page, @Partial, or @View");
}
ViewModel local = handler.render(req, res);
ViewModel merged = merge(req, local);
RenderOutput out = adapter.render(resolved.target(), merged.toMap(), req, res);
if (out.contentType() != null) {
res.type(out.contentType());
} else {
res.type(resolved.contentType());
}
return out.body();
}
ViewModel merge(Request req, ViewModel local) {
ViewModel global = computeGlobals(req);
return global.merge(local == null ? EMPTY : local);
}
private ViewModel computeGlobals(Request req) {
if (globals.isEmpty()) return EMPTY;
ViewModel out = ViewModel.empty();
for (ViewGlobals provider : globals) {
ViewModel vm = provider.provide(req);
if (vm != null) out.withAll(vm.toMap());
}
return out;
}
static ViewModel legacyLocalModel(Object legacyModel) {
if (legacyModel == null) return ViewModel.empty();
if (legacyModel instanceof ViewModel vm) return ViewModel.copyOf(vm);
if (legacyModel instanceof Map<?, ?> map) {
ViewModel vm = ViewModel.empty();
for (Map.Entry<?, ?> e : map.entrySet()) {
vm.with(String.valueOf(e.getKey()), ViewModel.unwrapValue(e.getValue()));
}
return vm;
}
return ViewModel.of("it", legacyModel);
}
}
@@ -0,0 +1,7 @@
package dev.relism.ext.view;
public record ViewTarget(
ViewKind kind,
String template,
String slot
) {}
@@ -0,0 +1,62 @@
package dev.relism.ext.view;
import dev.relism.http.ContentType;
import dev.relism.routing.Route;
import dev.relism.routing.Routes;
final class ViewTargetResolver {
private ViewTargetResolver() {}
static ResolvedView resolve(Class<?> handlerClass, EngineCapabilities capabilities) {
Page page = find(handlerClass, Page.class);
Partial partial = find(handlerClass, Partial.class);
View legacy = find(handlerClass, View.class);
int count = (page != null ? 1 : 0) + (partial != null ? 1 : 0) + (legacy != null ? 1 : 0);
if (count == 0) return null;
Route route = Routes.of(handlerClass);
if (route == null) {
throw new IllegalStateException("View handler " + handlerClass.getName()
+ " has view annotation but no route annotation (@Route/@GET/@POST/...)");
}
if (count > 1) {
throw new IllegalStateException("Handler " + handlerClass.getName()
+ " route " + route.method() + " " + route.path()
+ " declares multiple view annotations. Use exactly one of @Page, @Partial, @View");
}
if (page != null) {
return new ResolvedView(new ViewTarget(ViewKind.PAGE, page.value(), ""), ContentType.TEXT_HTML, false);
}
if (partial != null) {
String slot = partial.slot() == null ? "" : partial.slot().trim();
if (!slot.isEmpty() && !capabilities.supportsPartialSlot()) {
throw new IllegalStateException("Handler " + handlerClass.getName()
+ " route " + route.method() + " " + route.path()
+ " uses @Partial(slot=\"" + slot + "\") but current engine does not support partial slots");
}
return new ResolvedView(new ViewTarget(ViewKind.PARTIAL, partial.template(), slot), ContentType.TEXT_HTML, false);
}
return new ResolvedView(
new ViewTarget(legacy.fragment() ? ViewKind.PARTIAL : ViewKind.PAGE, legacy.value(), ""),
legacy.contentType(),
true
);
}
private static <A extends java.lang.annotation.Annotation> A find(Class<?> cls, Class<A> type) {
while (cls != null && !cls.equals(Object.class)) {
A a = cls.getAnnotation(type);
if (a != null) return a;
cls = cls.getSuperclass();
}
return null;
}
record ResolvedView(ViewTarget target, ContentType contentType, boolean legacy) {}
}
@@ -0,0 +1,56 @@
package dev.relism.ext.view;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class ViewModelTest {
@Test
void ofWithAndWithAll_populateModel() {
ViewModel model = ViewModel.of("a", 1)
.with("b", "x")
.withAll(Map.of("c", true));
assertEquals(1, model.toMap().get("a"));
assertEquals("x", model.toMap().get("b"));
assertEquals(true, model.toMap().get("c"));
}
@Test
void merge_otherWinsOnCollision() {
ViewModel global = ViewModel.of("nav", "global").with("app", "flash");
ViewModel local = ViewModel.of("nav", "local");
ViewModel merged = global.merge(local);
assertEquals("local", merged.toMap().get("nav"));
assertEquals("flash", merged.toMap().get("app"));
}
@Test
void toMap_isReadOnly() {
ViewModel model = ViewModel.of("k", "v");
Map<String, Object> map = model.toMap();
assertThrows(UnsupportedOperationException.class, () -> map.put("x", 1));
}
@Test
void nestedViewModel_isUnwrappedToMapRecursively() {
ViewModel nested = ViewModel.of("name", "Flash");
ViewModel root = ViewModel.of("app", nested)
.with("list", List.of(ViewModel.of("n", 1), ViewModel.of("n", 2)));
Object app = root.toMap().get("app");
Object list = root.toMap().get("list");
assertTrue(app instanceof Map<?, ?>);
assertEquals("Flash", ((Map<?, ?>) app).get("name"));
assertTrue(list instanceof List<?>);
assertTrue(((List<?>) list).getFirst() instanceof Map<?, ?>);
}
}
@@ -0,0 +1,48 @@
package dev.relism.ext.view;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ViewRuntimeGlobalsTest {
@Test
void merge_globalsThenLocal_localWins() {
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of(
req -> ViewModel.of("nav", "global").with("app", "flash"),
req -> ViewModel.of("nav", "global-2")
));
ViewModel merged = runtime.merge(null, ViewModel.of("nav", "local"));
assertEquals("local", merged.toMap().get("nav"));
assertEquals("flash", merged.toMap().get("app"));
}
@Test
void merge_nullLocal_keepsGlobals() {
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of(
req -> ViewModel.of("signedIn", true)
));
ViewModel merged = runtime.merge(null, null);
assertEquals(true, merged.toMap().get("signedIn"));
}
private static final class NoopAdapter implements ViewEngineAdapter {
@Override
public EngineCapabilities capabilities() {
return EngineCapabilities.NONE;
}
@Override
public RenderOutput render(ViewTarget target, java.util.Map<String, Object> model,
dev.relism.models.Request req,
dev.relism.models.Response res) {
return RenderOutput.html("");
}
}
}
@@ -0,0 +1,95 @@
package dev.relism.ext.view;
import dev.relism.http.ContentType;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import dev.relism.models.Response;
import dev.relism.routing.GET;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ViewTargetResolverTest {
@GET("/home")
@Page("pages/home")
static class PageHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/partial")
@Partial(template = "fragments/row", slot = "row")
static class SlotPartialHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/legacy")
@View(value = "legacy/home", contentType = ContentType.TEXT_PLAIN, fragment = true)
static class LegacyHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/bad")
@Page("a")
@Partial(template = "b")
static class ConflictingHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@Page("pages/no-route")
static class NoRouteHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@Test
void resolve_page_returnsPageTarget() {
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(PageHandler.class, new EngineCapabilities(true));
assertNotNull(resolved);
assertEquals(ViewKind.PAGE, resolved.target().kind());
assertEquals("pages/home", resolved.target().template());
}
@Test
void resolve_slotPartial_withoutCapability_failsFast() {
assertThrows(IllegalStateException.class,
() -> ViewTargetResolver.resolve(SlotPartialHandler.class, EngineCapabilities.NONE));
}
@Test
void resolve_legacyView_mapsToResolvedTarget() {
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(LegacyHandler.class, EngineCapabilities.NONE);
assertNotNull(resolved);
assertTrue(resolved.legacy());
assertEquals(ContentType.TEXT_PLAIN, resolved.contentType());
assertEquals(ViewKind.PARTIAL, resolved.target().kind());
}
@Test
void resolve_multipleViewAnnotations_fails() {
assertThrows(IllegalStateException.class,
() -> ViewTargetResolver.resolve(ConflictingHandler.class, new EngineCapabilities(true)));
}
@Test
void resolve_viewAnnotationWithoutRoute_fails() {
assertThrows(IllegalStateException.class,
() -> ViewTargetResolver.resolve(NoRouteHandler.class, new EngineCapabilities(true)));
}
}